Metrics: how & why
Every number the app prints traces back to one of a handful of formulas. This page is the reference for all of them: the exact formula, the file that implements it, and the reasoning behind the choice (which is usually the interesting part). It is the companion to Database & schema (the tables these read) and Data pipeline & refreshers (the jobs that fill them).
A condensed, assistant-only derivative of this page lives at
prompts/assistant-metrics.mdand is what the chat assistant injects as its methodology knowledge base. When a formula, convention, or caveat changes here, update that file in the same PR.
The app is read-only against PostgreSQL (onchain_credit schema). All metrics are computed at read time from snapshot tables, except a few asset-profile returns precomputed by the daily refresher. The canonical math lives in src/lib/data/apy.ts; per-page readers compose it.
The one annualisation convention
Every trailing APY in the app is the realised ratio of an on-chain compounding index between two blocks, annualised by the actual elapsed time. This is annualizeRatio in src/lib/data/apy.ts:
// ratio = index_end / index_start ; elapsedSeconds = actual seconds between reads
annualizeRatio(ratio, elapsedSeconds) = ratio ^ (SECONDS_PER_YEAR / elapsedSeconds) − 1with SECONDS_PER_YEAR = 365 * 24 * 60 * 60 = 31_536_000.
getTrailingApy(table, token, side, windowDays) is the workhorse: it finds the latest snapshot of an index, finds the latest snapshot at least windowDays older, takes end_idx / start_idx, and annualises by the real timestamp gap. The index column per (table, side) is a hardcoded whitelist (INDEX_COLUMN), so no user input reaches the SQL table/column interpolation; the token address (or the bytes32 market id for Morpho) and window are bound parameters.
| Venue | Table | Compounding index |
|---|---|---|
| Fluid Lending Layer | fluid_ll_apy | supply_exchange_price / borrow_exchange_price |
| Fluid vault (what THIS vault charges) | fluid_vault_rates | supply_exchange_price / borrow_exchange_price, keyed by vault_address |
| Aave v3 | aave_v3_reserve_apy | supply_index / borrow_index |
| SparkLend | sparklend_reserve_apy | supply_index / borrow_index |
| Yield wrappers / LSTs | token_yield_apy | share_rate (supply only) |
| Morpho Blue isolated | morpho_market_apy | share_rate (supply), borrow_share_rate (borrow, migration 041) |
The index is read as of the moment, not as of the last trade
A lending market's interest meter runs continuously, but most venues only write its reading into storage when someone touches the market. On a busy market somebody touches it constantly and the stored reading is never more than a few seconds old. On a quiet one it can sit unchanged for days while interest keeps accruing underneath.
Every reading this site takes is therefore the meter brought current to the block being measured, not the last figure written down. Reading the written figure instead would compare two readings taken at whatever moments the market last happened to be busy, and then report the growth between them as if it had happened over the six hours the window claims to cover. What that produces is not a small error but a fake one: a market whose rate never moved shows stretches of exactly 0%, broken by spikes that pay out several days of interest at once. The level was always right, because those errors cancel over a long enough window; the shape was not, and the shape is what a volatility, a Sharpe or a risk-adjusted carry figure is computed from.
Fluid was always read this way. Aave v3 and SparkLend now take the pool's own brought-current reading at each block, and a Morpho market's balances are advanced to the block before its share rates are formed.
Every series the site publishes is rewritten on the corrected basis, so none of them straddles the change. The rewrite covers each market across the span it already had readings for; it does not reach a market the platform has stopped tracking, whose stored history stays as it was and is not read by anything.
Publish gaps in a NAV-style share rate
The section above is about a lending index that keeps running between the moments it is written down. A yield wrapper priced off a published NAV has the mirror-image problem, and it cannot be fixed by reading harder: there is nothing to read until the publisher posts. A daily NAV mark that arrives six hours late, or skips a day, leaves a 24h window with no mark in it at all.
token_yield_apy.supply_apy is a trailing-24h reading, so a silent window prints exactly 0% and the window that catches up prints several days of accrual annualised over one — the same 0% / double / 0% sawtooth around an asset whose payout schedule never changed. reUSD in October 2025 is the reference case. It marks once a day, and through that month each mark was a steady 283.2 ppm, i.e. a rock-steady 10.9% a year. Then one mark landed late and carried two days (566.6 ppm, exactly twice the daily step), and the recorded series read: 22.6% on the late mark, 0% for the next four snapshots, then 10.9%. Nothing about the asset's income changed across those five days. Only the windows it was cut into did.
The repair is to measure the income over the window it actually happened in. When a mark lands more than 24 hours after the previous one, every snapshot from that previous mark through the new one is rewritten to a single figure: the realised growth between the two marks, annualised by the true elapsed time. Inside a gap the series then reads flat at the rate actually realised over that gap, instead of alternating between nothing and double.
On the October reUSD span the four 0% snapshots and the 22.6% spike that follows them all become 6.09% — the one mark that arrived over the following 42 hours, spread across those 42 hours. Note what that figure is and is not: it is the realised rate of that gap, not a restatement of reUSD's steady 10.9%. The repaired series can still be uneven, because the length of each gap is uneven — 6.09% over 42 hours and 10.9% over 24 are the same daily mark, divided by different amounts of elapsed time. What the repair removes is the part that was never earned in the window it was reported in: the zeros, and the double-counting beside them.
Five properties are deliberate:
- A long interval alone is never enough. The repair only fires on evidence that the series actually sawtoothed: at least one reading inside the span below 0.5%, the zero tooth. This matters because four of the tracked LSTs — wstETH, cbETH, rETH and tETH — rebase once a day, so they sit exactly on the 24-hour line, and anything that nudges one interval past it looks identical to a publish gap. A rebase landing a grid slot late does it; so does one of our own six-hourly snapshots going missing, which happens. In those cases nothing was ever silent, every reading was healthy, and spreading one day of accrual over thirty hours would simply mark the asset down. A smooth accruer never prints a zero tooth — every one of its windows contains an update — while a genuine publish gap always does, so this one test separates "the asset went quiet" from "our recording hiccupped" with no per-asset configuration at all. 0.5% is set from the materiality floor: a
1e-6move per snapshot compounds to ~0.146% a year, so nothing at or below that can be material accrual, and the real series leave the band wide open (largest observed zero tooth 0.137%, smallest healthy inside-reading 2.28%). - A continuously accruing asset is never touched. Its share rate moves materially every 6h snapshot, so the previous material change is never more than 24 hours back and the repair never applies. "Materially" is a relative move of more than
1e-6, which sits two orders of magnitude clear of both ordinary accrual (~5e-5 per 6h at 7% APY) and the float dust a stalled vault emits (~1e-8 per 6h). - Live behaviour inside a gap is unchanged. A snapshot taken while the publisher is silent still reads 0% — there is no income to report yet. Only the arrival of the next mark, which is the first moment the true length of the gap is known, repaints the span behind it.
- A gap closing on a rate DROP repaints negative. A realised drawdown spread over its true window is information, on the same principle as the rest of this page.
- A flat run longer than SEVEN DAYS is left exactly as recorded. The repair exists for a publisher running late — the longest legitimate skip in the table is about two days. A flat run of weeks is a different thing wearing the same shape: an asset that has stopped earning, parked or wound down or between strategies, whose 0% readings are the honest answer and not an artifact to smooth away. Averaging weeks of genuine nothing into a token annualised rate would state an income that was never earned. The cap also protects the case below: the first day of such a run still carries the trailing-24h echo of the one-off event that started it, and overwriting that echo would delete the event from the chart while leaving it in the table.
One more guard, on arithmetic rather than economics: a span whose annualised rate overflows the double range is skipped. That is not a hypothetical — a vault holding almost nothing during its seeding period can return a share rate eleven orders of magnitude off, and both the step into that reading and the step back out of it look like publishes. Whatever those rows should say, it is not infinity, so they keep what they have.
Failed reads are never rewritten: a NULL stays NULL, because the series has no observation there, which is a different statement from "earned nothing". apy_30d is untouched — a 30-day window already contains many marks, so the artifact is a rounding error there rather than a shape change. Only token_yield_apy is affected; the lending-index tables carry continuous on-chain accumulators with no publisher to wait on.
The math is one shared function (src/lib/data/apy-gap.ts) used by both the 6h refresher and the one-time historical repair, so a repaired row and a freshly written one cannot disagree.
A repainted reading contains look-ahead, deliberately. Everywhere else on this page a stored row is a function of the blocks at or before its own timestamp; docs/database.md's block-anchored convention says as much. A repainted supply_apy is not: its value is set by a publish that can land up to seven days after the row's snapshot_ts, because the length of a gap is unknowable until the gap ends. That is the whole point of the repair, and it is sound for what the column is used for — a chart of what an asset paid, and trailing-window statistics computed at read time, both of which look at the series as a finished history. It is not sound for a point-in-time reconstruction ("what did this row say on the day?"), and nothing does that today. Anything that wants that guarantee should read share_rate, which this repair never touches, and derive its own window.
A replay can un-repaint a span. scripts/backfill-recent-gap.ts and scripts/backfill-fluid-history.ts re-run the refresher for historical timestamps, and the write is an unconditional upsert, so a replay that lands inside an already-repainted span puts the raw sawtooth values back. Nothing restores them on its own: those snapshots are not material rate changes, so the live repaint sees nothing to do. Re-run scripts/backfill-yield-gap-repaint.ts --apply after any replay that writes token_yield_apy; see the runbook in data-pipeline.md.
One-off events stay in the data and are clipped on the chart
Some of what looks like a spike is not a window artifact at all. sUSDai booked a genuine +0.9% catch-up gain in a single day on 2026-06-08; annualised over 24h that is ~2,500%, and it is a true statement about that day. Readings like it stay in the series exactly as recorded — the app does not delete real history to make a chart tidy. (The seven-day cap in the section above is what keeps that promise: the six weeks of flat readings that followed the sUSDai mark are past the cap, so neither they nor the mark's same-day echoes are rewritten.)
They are handled at the point where they actually cause harm, which is the chart scale. The per-asset yield-history chart fits its y-axis to the bulk of the series (the 99th percentile plus headroom) rather than to its extremes. A reading above that bound is drawn at the top edge with a triangular marker, and its tooltip reports the true figure. A series with no such reading is scaled exactly as before, fitting every point. The result is that three years of a 5-8% asset stay legible instead of collapsing into a flat line at the bottom of the panel, and the one extraordinary day is still visible, still labelled, and still true.
Why not average per-snapshot rates
Averaging the per-snapshot annualised rates over a window overstates the true compounded return. The honest figure is the single realised ratio of the accumulator across the whole window, annualised once. The two agree only when the rate is constant; whenever it varies, the average of annualised sub-windows is biased high (Jensen's inequality on the x^(Y/t) − 1 map). Anchoring on the on-chain index also means a flat or declining window legitimately reads 0% or negative — share prices are not guaranteed to only go up, and a trailing APY anchored on a drawdown is real information, not a startup artifact (see the apy30dFromRates doc comment). Using the same convention at every venue is what lets /carries and /repo-lending compare like-for-like.
Two precision helpers
indexRatio(end, start)scales two same-scale bigint readings to 18 digits before collapsing toNumber, so a tiny short-window growth (~3e-5 over 6h) keeps full precision.getTrailingFeeApy(pool, windowDays)is the exception to the index rule. A Fluid DEX pool fee is a flow, not an accumulator, so there is no ratio to take. It sumsfee_window_usdover the window and divides by the summed pool TVL (tvl_usd_smart_col + tvl_usd_smart_debt), then annualises by×1460(=365 × 4, the 6h-snapshot cadence). The summed-over-summed form is a TVL-weighted mean of each window's fee yield; the row count cancels, so it is correct for any window length, and simple×1460annualisation is right for a flow yield that is not auto-compounded.
Trailing APY on a carry leg (composition)
A carry leg (collateral or debt) is rarely one index — it is a composite, and each component is measured by its own correct trailing method, then summed. This is implemented two ways that must agree:
- Time series for the chart:
getCarryHistoryinsrc/lib/data/carries-table.tsjoins the underlying tables per 6h snapshot (getT1History,getT2History,getT3History,getT4History,getT4CrossHistory,getEmodeHistory). - Row figures for the table / simulator presets:
carryLegComponentsdecomposes the leg intogetTrailingApy/getTrailingFeeApycalls and sums them. Same decomposition, so the table and simulator can never diverge.
The composition rules (identical on both paths):
- Collateral (target): wrapper appreciation (
token_yield_apy.share_rategrowth, e.g. wstETH/weETH/sUSDe) plus the venue supply interest (fluid_ll_apy/ Aave / Sparksupply_apy), eachnull → 0. Summed, not either/or — a wrapper's venue supply rate is ~0 today but is added anyway, symmetric with the debt side. Thenull → 0holds only for a token with notoken_yield_apyseries at all (a plain reserve genuinely earns no wrapper appreciation). For a token that HAS a series, a missing snapshot is a data gap, not a 0% yield: see A missing wrapper row is a gap, never a zero. - Debt (funding): the venue borrow rate plus the debt token's own wrapper appreciation when it is a yield-bearing wrapper. Additive because you owe the debt in wrapper units, so the USD-denominated debt grows at both the venue rate and the wrapper's price appreciation.
- Fluid: the venue borrow rate is the VAULT's, not the market's. Fluid prices borrowing per token at one shared Liquidity Layer, and then each vault applies its own term on top of it, so the layer rate is what the market charges and not what a given vault's borrower pays. Every Fluid funding leg — live and historical — therefore reads
fluid_vault_rates(the vault's own borrow exchange price, keyed by vault address) rather than, or in addition to,fluid_ll_apy:- T1 / T2 (single-token debt): the vault index replaces the layer's borrow term. The vault's index grows at the layer's growth times
borrowRateMagnifier / 10000, so at 1x (every live vault today) the two are the same series and no published figure moves. Fluid routes borrow incentives through that magnifier, so a vault can sit below 1x for as long as a programme runs, and the carry then reflects the cheaper cost. - T3 / T4 (smart debt): the tokens' interest accrues inside the DEX borrow share, so the vault index carries the vault's own fixed overlay alone. It is a THIRD funding term next to the share-weighted token rates and the pool fee, signed as a cost: positive when the vault charges its borrowers, negative when it pays them. It is 0 on all seven live smart-debt vaults, and appears in the contribution breakdown as "Vault rate adjustment" only when it is at least a tenth of a basis point. The breakdown lists every component the displayed window carries, not only those present at its first snapshot, so a term that starts part-way through the window — a vault that switches a charge on, which is the state this series exists to catch — is drawn, named and scaled for from the snapshot it starts on.
- A snapshot with no vault row is withheld, never filled in. There is no fallback to the layer rate (that would publish the market's price as the borrower's, the defect this exists to remove) and none to zero (which would publish free borrowing). The expanded row states the funding cost on both bases, the market's rate and the vault's, whenever the two differ by at least a basis point, and says nothing when they agree.
- The two bases are stated only when they are comparable. Each trailing window is anchored on its own table's latest snapshot, so a vault whose rate row is one 6h window behind the layer's is measured over a different 30 days: re-anchoring by one window moves a 30d trailing borrow APY by up to ~4 bp on the real series, which is the size of a real vault-level term. The pair is therefore published only when every component the two decompositions do not share resolved the SAME two snapshots, and every component they do share resolved the same snapshots at the same weight. Otherwise the row says nothing rather than reporting a series lag as a charge.
- T1 / T2 (single-token debt): the vault index replaces the layer's borrow term. The vault's index grows at the layer's growth times
- Fluid DEX pools (T2/T3/T4): the per-token supply/borrow legs are USD-reserve-weighted across the pool's two tokens, and the pool fee (
fee_apy_usd) is added to the collateral side and subtracted from the debt side. For a same-pool T4 the trading fee is both earned (collateral) and saved (debt), so its net contribution is2×the per-leg fee. A cross-pool T4 (t4-cross-pool, smart-col and smart-debt in different pools, e.g. vault 169 PST/USDC → USDC/USDT) joins TWOfluid_dex_apyrows per snapshot: the collateral side earns the COL pool's fee + its smart-col-weighted supplies, the funding side pays the DEBT pool's smart-debt-weighted borrows minus the DEBT pool's fee — two different fee numbers, listed as separate contribution entries, never2×one. Its leverage loop is NOT swap-free: the simulator converts the borrowed debt-pool pair into the col-pool pair with the overlap netted (crossPoolLegsinsrc/lib/sim/cross-pool-legs.ts— a token on both sides moves 1:1 at no cost, only residual flows are Kyber-quoted). - Morpho Blue (
morpho-blue): target = the collateral wrapper's appreciation fromtoken_yield_apy(or, for PT collateral, the pendle implied rate). There is no additive venue-supply term — Morpho collateral is never lent out, so it earns nothing at the venue (simpler than Aave/Spark). Funding = the market's realised borrow-index ratio (borrow_share_rateinmorpho_market_apy, viagetTrailingApy), keyed by the bytes32 marketId; the loan is always a base stable so there is no wrapper term on the debt. Note the LLTV is both the max LTV and the liquidation threshold on Morpho, so a Morpho carry has zero buffer between them: borrowing at the ceiling liquidates on any adverse move, and the max-lev figure (e.g. ~28.6× at 96.5% LLTV) is truthful, carried by the copy rather than fudged.
A missing wrapper row is a gap, never a zero
The 6h token-yields refresher writes no row at all when its on-chain read fails (it never persists a zero — the same honesty rule as M9 and the lending refresher). Every history reader therefore LEFT JOINs token_yield_apy and must distinguish two different NULLs:
- the token has no series (a plain reserve: USDC, WETH, GHO) → it earns no wrapper appreciation, and
COALESCE(..., 0)is the correct answer; - the token has a series but is missing at this snapshot → a refresher gap. Coercing that to 0 fabricates a collapse to 0% collateral APY and a negative net carry for the snapshot.
wrapperRowPresent in carries-table.ts encodes exactly that distinction, and guards every wrapper join (Morpho, Aave/Spark e-mode, the PT debt legs, and the Fluid debt legs; getT1History's collateral leg already required IS NOT NULL, which is why Fluid carries never showed the artifact). A gapped snapshot is dropped, not zeroed, so the chart's daily resample falls back to the next snapshot of the same day, and the point never reaches the vol windows.
This is not hypothetical. The 2026-06-25 00:00 UTC refresher run wrote only 4 of 78 tokens, and the resulting COALESCE(..., 0) printed a false one-day drop to 0% collateral APY on every Morpho and Aave/Spark wrapper carry (sUSDS/USDT's net carry read -2.54%). The chart's own null-skip guard could not catch it, because the COALESCE had already turned the NULL into a legitimate-looking 0.
The calculator's 7D/30D/90D chips select a WINDOW, not a rate
Both APY inputs on a variable-rate carry carry a "Trailing APY" chip row (ApyPresetRow in CarryChart.tsx). The field opens on the 30d window (defaultApyPct, preference 30d → 7d → 90d: the longest window that still tracks the current rate regime), and the chip lit on open is the one that names that window — both derive from defaultApyWindow, so the number shown and the chip claiming it cannot disagree.
These chips serve getCarryRow's targetWindows / fundingWindows: the trailing-index figure (one realised index ratio over the window, annualised once). That is not the collapsed row's headline, which uses targetMean30d — an arithmetic mean of daily annualised prints. The two differ whenever the leg's rate moved during the window, so the calculator opening at 4.37% under a 4.41% headline is expected, not a data fault. Do not describe either figure as the other.
The lit chip is the window the user last picked (activeApyWindow), held only while the input still carries that window's rate; typing a value that moves off the window (further than the ±0.005pp match band) deselects all three. Selection is not inferred by matching the input against each window's rate, because the rates tie: a governance-set savings rate barely moves, so sUSDS reads 3.60% at both 7d and 30d. Under a value-matching rule the earlier chip always wins, which leaves the other permanently unlightable and inert on click (it only ever re-sets a value the field already holds) — the morpho-susds-usdt "30D does nothing" bug. A tie is normal on any slow-moving leg, not a data fault; the chips must stay independently selectable through it, and exactly one is ever lit.
A window with no data disables its chip, and 0 is not "no data". A market listed under 90 days ago has nothing measuring its 90d window. combineLeg coerces each missing component to 0 (correct for composition: a leg's absent venue-supply term really does add nothing), but a whole window that measured nothing summing to 0 is a different statement — and a 90D chip offering "0.00%" on the funding leg reads as free borrowing, inflating simulated net carry by the entire debt leg. getCarryRow therefore emits targetWindows / fundingWindows via combineLegParts, which reports whether any component was measured and keeps an unmeasured window null; the chip disables. There is no 0-coerced twin of those fields, and there must not be one: arithmetic on a published figure is exactly where the null has to survive, so every consumer that levers or differences the pair derives it from the null-preserving windows themselves (carryRealised7d for the realised week). legIsMeasured withholds a window on a second rule as well — a leg missing a component it cannot be stated without (a Fluid market's own borrow rate) is unmeasured however much of the rest read, so the coercion cannot reappear one level down.
Term carries (Pendle PT collateral)
A PT is a zero-coupon claim: bought at a discount, it accretes to par in its accounting asset at maturity, so the collateral leg's yield is the fixed implied rate locked at entry (held to maturity), not a floating index. The carry reader therefore sources the PT collateral leg from pendle_market_state.implied_apy (getEmodePtHistory in carries-table.ts, LATERAL latest-at-or-before join within 7 days to bridge 6h rpc rows and daily pendle_api backfill rows) rather than token_yield_apy. The series answers "what fixed carry was on offer at t"; the debt leg is the venue's variable borrow rate, identical to every other e-mode carry.
Three caveats that hold for every PT row:
The historical series are offered-carry statistics, not held-position statistics — so the headline figures do not use them. A holder's collateral rate is locked at entry, so weeks where the offered implied dipped never hurt an open position's accrual, and a trailing mean of past entry quotes is a rate nobody can lock today. The collapsed row's Carry and Max-lev columns therefore price the collateral leg at the CURRENT fixed rate (
targetFixedToday, across every trailing window), and so does the Carry range column beside them; see The collapsed row's headline under Net carry, and the staleness rule (no quote in 24h → NoData dash, never a fallback to the mean). This supersedes the earlier decision to accept offered-carry statistics in the headline: an unobtainable max-lev APY on the default sort key was judged misleading, and the estimator, not the caveat, was the thing to fix.The per-date offered series remains everywhere the question is genuinely historical: the worst-week / stability stats, and the APY and carry-differential charts (labeled "fixed rate on offer" — they answer when the trade was attractive to enter). The cum-return chart stays entry-locked: the collateral leg compounds at the rate locked on the selected window's first date against the realized funding path ("PT ENTRY = WINDOW START" chip), which is what a looper entering that day actually accrued. A custom window-length input (days) on all carry charts sets that entry date precisely. What a rate-quote series still cannot express at all is a holder's true early-exit risk (mark-to-market duration loss when implied rates spike). That is now quantified on the row's Market depth step, which for a PT collateral prices the rate move rather than charting a peg: see PT collateral under Secondary market basis.
The simulator's fixed-rate input has no trailing presets. The field is labeled "Current fixed rate" and prefilled with the latest implied snapshot (the rate an entrant locks); its single preset restores that value, with an asterisk line naming the source. A locked rate is not a trailing statistic, so the 7d/30d/90d row is omitted for term carries. The holding period defaults to the full term with a single "To maturity" preset and a bare "PT matures
<date>" asterisk line. Exit costs are modeled by horizon: a hold that reaches maturity prices the exit as redeem-at-par plus one proceeds swap into the funding asset (the SY'sgetTokensOut()names the redemption outputs on-chain — often not the accounting asset itself, e.g. the srUSDe SY pays srUSDe or sUSDe, while a plain PT-USDe SY pays USDe and the swap degenerates to zero; the route picks the output with the cheapest quotable route, falling back to the PT-sale figure, reported as such, if none quotes), while an early exit prices a PT market sale on Pendle. Both are quoted at today's liquidity, in practice a proxy for the future exit state.The venue's mark is not the market price. Aave prices the PT by a governance-set linear discount (currently a HIGHER discount rate than the market implied, i.e. the adapter marks below Pendle's price), so effective entry leverage and liquidation distance are tighter than pure LTV arithmetic. The ORACLE panel states this (the simulator's footnotes were deliberately minimised and no longer carry it). A quantitative haircut using the snapshotted
pt_to_asset_ratevs the adapter mark is a tracked follow-up (#303).
The simulator clamps the holding period to maturity (the trade stops existing there) and projects the collateral leg at the locked rate; exiting before maturity realises the prevailing market-implied rate instead, which the oracle panel copy states explicitly.
Net carry
net carry = collateral APY − funding APYcarry = collateral trailing APY − funding trailing APY over the same window. The leg APYs are the composed trailing figures above. This is the per-unit (unlevered) spread.
Both legs must have been measured. A trailing window figure is null — not 0 — when the window predates the leg's series or a component the leg cannot be stated without went unread (ApyWindowSet, legIsMeasured), and a spread needs both sides: carryRealised7d returns nulls throughout unless the week measured the collateral leg AND the funding leg. This matters most on a Fluid market listed inside the window, whose own borrow index has no 7-day anchor: its funding leg is withheld, and summing the rest as though the missing term were 0% would publish the trade as funded at nothing.
The collapsed row's headline: a position opened TODAY
Every APY figure in the collapsed /carries row (Carry, Max-lev, Carry range) answers one question: what does a position opened today earn, each leg measured by its best estimator? That resolves to two methodologies, branched in carryHeadlines (src/lib/data/carries-table.ts) and shared by the page and the assistant's carry catalog so the two surfaces cannot diverge. The Carry and Max-lev columns additionally switch trailing window (24h / 7 day / 30 day; see below); the table here is the 30d default and Carry range stays 30d always.
| Cell | Variable-rate collateral | Term (PT) collateral |
|---|---|---|
| APY · Carry (30d) | targetMean30d − fundingMean30d | targetFixedToday − fundingMean30d |
| APY · Max-lev (30d) | L·targetMean30d − (L−1)·fundingMean30d | L·targetFixedToday − (L−1)·fundingMean30d |
| Carry range (30d) | P10(carryDaily30d) and P90(carryDaily30d), sorted on P90 − P10 | the same two percentiles of targetFixedToday − fundingDaily30d |
| Funding shock | spotApy(U₀ + 5pt) − spotApy(U₀) on the funding market's own curve | unchanged (a scenario against the funding market, not the collateral leg) |
The Carry and Max-lev rows in the merged APY column switch trailing window (24h / 7 day / 30 day), via the same WindowSwitch the Repo-lending page uses, labelled Trailing; the control carries the selected window while the table header remains APY. The table above is the 30d default. carryLevForWindow (carries-table.ts) generalises carryHeadlines over the window and its 30d result is identical to the table by construction, so the default view, its sort, and its min-thresholds are unchanged. The funding leg re-estimates per window — the daily-sample mean at 30d / 7d (fundingMean30d / fundingMean7d, the same non-overlapping daily sample the risk stats read) and the realised trailing-24h rate at 24h (fundingWindows.d1, since a one-day window has a single daily observation and no mean). A variable collateral leg re-estimates the same way; a term (PT) collateral leg stays pinned to targetFixedToday across every window (you cannot lock a past week's rate — only the funding assumption moves). The switch also drives those two columns' sort and their Carry ≥ / Max-lev ≥ thresholds, so filtering tracks what is on screen. Carry range and Funding shock are NOT windowed: the first is pinned to the 30d daily sample, the second is a scenario against the funding market as it stands, and neither moves with the switch.
Carry range (30d)
The collapsed row's sixth column, headed Carry range 30d over P10 TO P90, which replaced Vol-adj APY in 2026-08 (#669). It shows the band itself: the 10th-percentile day and the 90th-percentile day of the last 30 days of daily net carry, in percent to one decimal, on the same basis as the Carry figure beside it (for example, 2.0 – 3.3 %). One day in five fell outside the pair.
The window is named in the column title, not in the qualifier under it. This column does not follow the Trailing control (see Window below), and a reader who has just moved that control should be able to see that from the column name rather than from a second line set at 8.5px.
The name is the longest any column on this screener carries, and at one width it does not fit its track: at a 1320px window, the point where the three measured columns sit on their minimums, it renders Carry range 3… with the ⓘ beside it carrying the definition. It is the track that decides this, not the zoom step — a 1360px window is the same step and shows the name in full. The track cannot be widened without pushing the table into a horizontal scrollbar on a laptop. architecture.md has the measurements.
Until 2026-09 the cell printed the DISTANCE between those two days as a basis-point figure. That answered "how much did carry vary" without ever saying what it varied between, so a reader comparing a row's Carry against its own recent range had to open the panel to find out where that carry sat. The band answers both at once, and the distance is still what the column sorts on.
Precision is chosen for the pair, not per figure. Both ends print to the same number of decimals, and both drop a decimal together where either would outrun the cell's slot: a band printed −10 – 2.0 reads as a typo rather than as a span. The detail panel always carries the full precision.
The full distribution and statistics open from the small mark beside the band, not from the figures themselves — see The detail mark below.
The sample. One observation per UTC day over the trailing 30 days, from the same dailyLegSample the vol / mean statistics read (see Carry volatility below), so the picture and the numbers cannot disagree. Which quantity is plotted depends on the carry kind, and it is resolved server-side by carryRangeSeries (src/components/carries/carry-range.ts):
| Row kind | The 30 values |
|---|---|
| Variable-rate collateral | carryDaily30d — the daily net carry itself, both legs floating |
| Term (PT) collateral | targetFixedToday − fundingDaily30d — today's lockable fixed rate against each of the last 30 days' funding observations |
The term branch is the same principle as the headline: only the funding leg floats under an open PT position, because the collateral rate was locked at entry. The historical entry spread (what a new position would have earned had it been opened on each past date) answers a different question and stays on the expanded row's charts. A term row with no fixed-rate quote in the last 24h has nothing to plot and dashes, exactly as its Carry and Max-lev cells do.
Percentiles use linear interpolation between sorted observations (rank (n − 1) · p, the R-7 / Excel PERCENTILE convention). The two visible figures ARE P10 and P90, and the detail panel reads the same carryRangeStats object to show the daily distribution plus latest, median, average, P10, P90, best-day and worst-day statistics — each suffixed APY, since a reader arriving from the Carry column beside them has to be able to compare the two without inferring what the percentage is a percent of. The two surfaces cannot disagree.
Validity. Non-finite observations are dropped, never coerced to zero (a missing day is not a zero-carry day). Fewer than 7 valid observations renders the no-data dash and sorts last in both directions. Between 7 and 29, the column renders and discloses the shortfall: the accessible label always states "N of 30 days observed" and the popover adds a Days observed row. A flat series (P10 = P90) is valid data, not missing data — it is the reading that says a rate did not move.
Window. Pinned to 30 days. The Trailing control (24h / 7d / 30d) moves Carry and Max-lev only; a 24h distribution would hold one observation.
Sort. On the raw, unrounded P90 − P10 distance between the two figures the cell prints: a narrower band is a steadier carry, so lower is steadier. The active-sort caret sits on the header's P10 TO P90 line rather than beside the title, and the whole two-line header is the sort control (see Screener column headers in architecture.md). Selecting the column starts in ascending order, so the steadiest strategies appear first; selecting it again reverses the order. Ties break on market size (largest first) so the order is deterministic. Rows with fewer than 7 observations sort last in both directions. Vol-adjusted carry remains the assistant's ranking metric (see Carry volatility below); it is no longer a table column.
Borrowable / utilization
The collapsed row's fourth column, headed Borrowable over UTILIZATION, two lines:
- line 1 — borrowable entry headroom in USD (
borrowableUsd), compact to two decimals with a K / M / B suffix. Values below $100k render red: the room is then thin enough to be the constraint on the trade rather than a detail of it. - line 2 — how much of that trade's funding market is already lent out, to one decimal. It is the same reading the expanded row itemises per market, put through the binding-leg rule: a trade funded by two markets (Fluid smart debt) shows the leg closest to, or furthest past, its own escalation level (
worstFundingLeg,src/lib/data/carry-utilization.ts), because that is the leg deciding what the position pays next. Ranking the raw percentages would show the calmer market on a row where the other one is the problem. Omitted, rather than dashed, where no leg publishes a utilization: the column's subject is the dollar figure above it.
At or above 99% the utilization figure renders red — there the next borrower pays the steep end of the market's curve and the next lender may not be able to withdraw at all.
Read the two together. A large dollar headroom in a nearly-full market is the last of the room, priced at the steep end; the same amount in a half-empty market is ordinary depth.
Two readings of one market, and why they can differ. Utilization here is NOT taken from the Funding shock payload, which carries one too: that payload exists only where the shock is healthy and fresh, so sourcing it there would blank this line on exactly the rows whose funding market could not be modelled. The two are therefore read at different moments — this line from the reserve/layer snapshot tables (getDebtMarketUtilization, refreshed hourly and accepted up to 2 days old), the panel's per-market figure from the funding-shock block (every 6h, hidden past 12). On a market whose utilization is moving they will not agree to the decimal, and the panel's is the one anchored to the block its rates were read at. Neither is stale in the sense that matters — both are bounded — but a reader comparing the row against the panel should expect the row to be the fresher of the two.
The log bar is gone (2026-09). It plotted the dollar figure on a clamp((log10(max(usd, 1)) − 5) / 3.3, 0.03, 1) scale from $100k to $200m, which is a shape rather than a comparison: two markets an order of magnitude apart drew nearly the same bar, and the scale had to be explained here to be read at all. The width it took now carries the second figure instead.
A Min Borrowable filter (the $M field in the filter bar, entered in millions) keeps only rows whose borrowableUsd ≥ the threshold, and — unlike Carry / Max-lev — it is not windowed. Since 2026-08-03 it ships set to $100k (the listing floors admit real-but-tight markets; the default screen filters them instead): an absent liqmin URL param means the default, an explicit liqmin=0 sentinel means cleared so the choice survives reload, and a junk param falls back to the default. A row with no liquidity reading drops out under a threshold the user typed, but passes the untouched default — a prices/refresher outage must blank the Borrowable cells, not empty the page.
A second filter reads the market's size rather than its remaining room. Min Borrowed (the $M field beside Borrowable, also entered in millions) keeps only rows whose underlying market currently carries at least that much outstanding borrow, the same figure the expanded row reports as Total borrowed and, on the pooled venues, as Reserve borrowed: per market on Fluid and Morpho, and for Aave v3 / SparkLend the whole debt reserve, since those venues pool lending by reserve rather than by pair. Because the screen compares that figure across venues, the field carries the same note in an explainer beside its label; a pooled reserve clears any ordinary floor on its own, so the threshold does its real work on the isolated Fluid and Morpho markets. It measures how much capital the market already carries, not how much of it any one strategy has drawn, and like Borrowable it is not windowed. It ships set to $1M, so the first screen stays on markets with real borrowing behind them while smaller ones remain one click away. The URL contract matches the liquidity field: an absent bormin param means the default, an explicit bormin=0 sentinel means cleared so the choice survives reload, and a junk param falls back to the default. A row with no borrow reading drops out under a threshold the user typed, but passes the untouched default, for the same reason: a refresher outage must blank a KPI, not empty the page. Since the shipped floor and a hand-typed one can spell the same number, a threshold you set is written to the link even when it matches the default, and a screen shared that way keeps the stricter treatment of a market whose size cannot be read.
Funding shock
What it answers. If borrowing demand in this trade's funding market rose by five percentage points of utilization, how much dearer does the debt get, and how much additional borrowing would it take to move the market that far? It replaced the Funding pressure column (2026-09), which asked how CLOSE a market was to escalating rather than what escalating would cost.
Two outputs, read together:
- the rate change, in basis points of borrow APY. It applies one for one to the unleveraged net carry, and by leverage minus one on the max-leveraged figure: that column is
L·target − (L−1)·funding, so only the borrowed part of the position reprices. On a 70%-LTV pair (L ≈ 3.3) reading it asL ×would overstate the hit by 43%. - the additional borrowing, in USD, with supplied liquidity held constant. A market where five points cost little and take a large amount of borrowing to reach is deep and hard to move; one where a modest amount tips the cost sharply is where a crowd can compress this carry.
It is a scenario, not a forecast. The market's own curve is repriced at a utilization it is not at, with every other model input held constant. Nothing here predicts that utilization will rise, and for an adaptive model nothing simulates where the rate would drift if it stayed high.
What the dollar figure is not. It is a pure additional-BORROW move. It is not the supplier withdrawal that would produce the same utilization, and the two must not be read as interchangeable. Nor is it necessarily executable today: a protocol borrow cap or a venue utilization ceiling can bind, which the detail panel discloses separately rather than truncating the curve scenario.
The calculation, per venue. Every input for one strategy — balances, curve parameters, token decimals, prices, contract bytecode — is read at ONE archive block, and the calculators work in the protocol's own integers until the final display conversion.
- Aave v3 and SparkLend. The reserve's own deployed rate strategy is simulated twice through
calculateInterestRates, at the current state and at the post-borrow one, so the deployed contract's parameter lookup and rounding are inherited rather than reimplemented. The stressed call models the action as BOTH more debt and less cash; raising the debt alone would model borrowing that never left the pool. The denominator is the strategy's own (Aave's virtual balance, SparkLend's aToken balance) plus the debt, never a UI TVL. Whether a reserve is priced against a virtual balance at all is read PER RESERVE: a mint-only reserve has it switched off, its rate is then a flat constant rather than a curve, and it renders no data rather than a zero shock over a denominator the protocol ignores. Rates are converted to an APY with each venue's own one-yearcalculateCompoundedInterestform; the two vintages differ by hundredths of a basis point, so each is converted by its own. - Morpho Blue. The AdaptiveCurve IRM is ported exactly. The market is first brought forward to the block the way Morpho itself does, and the resulting target rate is then FROZEN while only utilization moves. The venue's own
borrowRateViewcannot answer this directly: it returns the average rate over the interval since the market was last touched, and calling it with stressed totals would tell the model the higher utilization had been in force for that whole interval and let it move the target rate retroactively. - Fluid. The Liquidity Layer's per-token curve is ported literally from
calcRateV1/calcRateV2, including the order of its truncations. Fluid prices borrowing per TOKEN at the shared layer, over utilization aggregated across every product borrowing it, so a vault's own utilization drives nothing. A vault'sborrowRateMagnifiermaps the layer rate onto what its own borrowers pay, applied to both rates with the magnifier held constant. That is the VAULT's rate, not the token's, and it is the SAME BASIS the Carry column uses: every Fluid funding figure on the platform is the vault's own rate (fluid_vault_rates, above), so a row away from 1x no longer has two columns quoting two rates for one position. The two are still measured differently and neither substitutes for the other: this column evaluates the layer's CURVE at today's state and at a stressed one and maps it through the magnifier the vault carries right now, while the Carry column is the realised growth of the vault's own index over a trailing window. The detail panel still names the multiple on a row away from 1x, because the level it shows is then not the venue's published rate for the asset. The smart-debt overlay is not mapped here: on a smart-debt vault those same sixteen bits are a packed sign and magnitude rather than a multiplier, so this column passes 1x and carries no overlay term, while the Carry column carries it from the vault's own index. Fluid writes a new stored rate only once utilization has moved past a threshold, and the shock is evaluated on the CURVE rather than on that stored pair, so a move smaller than the threshold is not reported as a flat one.
Two funding markets. A Fluid smart-debt position borrows two Liquidity Layer tokens through one DEX borrow-share position. Each is shocked from its OWN utilization by up to five points on its own curve; the rate changes are weighted by the USD value of the pool's per-borrow-share amounts (the vault's own debt composition, not the layer's market-wide split), and the two dollar figures are summed into the one the cell shows. This is a parallel two-market stress, not a claim that one transaction can move both utilizations by exactly five points: the two books are shared with everything else borrowing those tokens, and the pool's own borrow-share ratio and borrow limit need not permit the pair. The detail panel says so beside the blend and itemises both markets, so the summed dollar figure is not read as a single executable move. The blend the panel leads with is a BORROW rate across the two funding markets, not what the position nets out at: the pool's own trading-fee income and the vault's fixed rate adjustment do not move with utilization, so they are held constant and cancel from the change, and the panel says so rather than letting the level read as this trade's funding cost.
Three utilization states. The stress target never exceeds 100%.
| Current utilization | Target | What the cell shows |
|---|---|---|
| below 95% | U₀ + 5pt | the rate change and the borrowing behind it |
| 95% to below 99.99% | 100% | the same, plus to 100%: the move is the remaining headroom, not a hypothetical five points |
| 99.99% or above | none | No headroom and $0 borrow |
99.99% is the documented tolerance for "effectively 100%". One basis point of headroom on any real book is dust, and reporting a shock there would price a move too small to borrow next to a utilization the cell rounds to 100%. It is stated in the same unit the utilization is floored to, so the displayed percentage and the state can never contradict each other. A source that reports utilization ABOVE 100% (bad debt, unbacked balances, venue-specific accounting) takes the same no-headroom state and discloses its own figure rather than applying a negative shock.
A market with no headroom is repriced where it stands, so its published change is exactly zero rather than the change to some target it is already past. That matters on a row funded by two markets, the one place such a market is not short-circuited to No headroom: the row's rate change is then the market that still has room, carried at its own share of the funding cost, and the dollar figure is that market's alone.
Presentation. Two lines: the rate change in whole basis points over the one dollar figure, with a third line naming the cap where the move stopped short. The SIGN is coloured, the magnitude is not. A rise in funding cost is the risk case and a fall is not, and which of the two a row is in should not need reading digit by digit; but no threshold is drawn between magnitudes, because whether a rate change matters depends on the carry it eats into, which is the column two cells over, and a line between "+37 bp" and "+700 bp" would be an editorial judgement this column has no basis for. An exact zero takes neither colour, since it is neither. The scenario reaches assistive technology as a sentence, since neither line says on its own that this is a hypothetical move.
The rate curve. The detail panel draws each funding market's own borrow curve under that market's figures: utilization 0 to 100% across, borrow APY up, a tick at every breakpoint of the model's curve, a dot where the market stands now and a second where five more points would take it, with the move shaded between them. Every breakpoint gets a tick mark; only its LABEL is dropped where the number would collide with the axis ends, so a kink at 95% is marked under the bend it makes but not numbered beside the "100". What the two printed figures cannot say is WHERE on its own curve the market is standing, and five points cost little in the middle of a flat stretch and a great deal just under a kink.
- The y axis is scaled to the position, not to the curve: its top is twice the higher of the two scenario APYs, so both markers always sit in the lower half and the curve's tail runs off the top of the plot. That is the intended reading rather than a clipped drawing — a plot scaled to the tail would draw every live market as a flat line on the floor. The x axis is always the whole 0-100% range, never the sampled range, so a kink's position is comparable between rows.
- A market with no headroom draws one marker where it stands and no shaded move. "Where it stands" is
min(utilization, 100%): a market at 99.99% marks at 99.99%, and one whose source reports more borrowed than supplied is clamped to the edge for drawing only, with the panel's text carrying the real figure. - A market whose model adapts (Morpho) draws the instantaneous curve at the target rate the run froze. The adaptive note beside it is what says the curve itself keeps moving.
- A row published before this shipped draws no chart, and no placeholder either. See Where the curve comes from below.
The detail mark. Both detail panels — Funding shock and Carry range — open from a small mark of five bars, and from nothing else. The figures themselves are static text. Before 2026-09 the figures were the hover target, which made a whole band of the row quietly interactive: nothing distinguished a value with a panel behind it from one without, the hit area moved with the length of the number, and a reader scanning the column triggered panels they had not asked for. The mark is a tab stop, opens on hover, focus, Enter, Space or a tap, closes on Escape, an outside press or a scroll that moves the mark, and the pointer may travel from it into the panel.
Where the mark sits. On the value's own line, 8px after its unit, and it turns amber while its panel is open. It used to be a framed 18px box parked at a fixed x down the column so that every row's mark lined up; that made it a second object competing with the figures in two of the seven columns, and on the many rows whose figures are short it sat a long way from the number it belongs to. Unframed and attached to the value, it cannot be ambiguous about which value it opens, which is the whole job of the mark. On Funding shock the borrow line below it is not a second trigger, and neither is the "capped" line under that. The scroll rule is about the mark moving, not about a scroll happening: reaching a mark below the fold by keyboard makes the browser scroll it into view as part of focusing it, and that is the scroll that opened the panel rather than one the reader made. Each panel opens above its mark and centred on it, dropping below it, or beside it, wherever there is no room above.
Where the curve comes from. The 6h refresher publishes the sampled curve INSIDE the same payload as the two figures, evaluated in the same run, from the same fingerprinted parameters, through the same model code path — never re-read from the chain and never re-derived by the page. Both scenario points are pinned onto the sampled curve, so the marker drawn from a leg's own figures lands on the polyline drawn from its curve; the chart is therefore incapable of disagreeing with the numbers above it. A no-headroom leg pins at most one point: its "stressed" pair is today's rate at the grid's 100%, which is not a state of the curve at all.
A changed curve parameter reaches the drawn curve within one refresher cycle (6 hours), the same bound as the figures, and for a simpler reason than the fingerprint: nothing is cached. Every run re-reads each venue's own state at that run's block and re-samples the curve from it, so a governed change is in the next published curve whether or not the fingerprint names the field that moved — and it deliberately does not name all of them (SparkLend's kink rate follows an external feed that moves daily, so variableRateSlope1 is excluded there, or every run would announce a configuration change). The fingerprint's job is a different one: deciding which configuration a stored row belongs to. The reader hides any row older than 12 hours either way. The payload gained a field and the calculators did not change, so modelVersion is unmoved and no migration was needed (result is jsonb). Rows written before the field existed are still served and render their figures with no chart.
A chart that cannot be drawn never costs a reader a number. The curve is sampled after the venue's own parity gate has already accepted the figures, so anything wrong with it is a defect in the drawing alone. The writer drops such a curve, records why in the row's diagnostics and publishes the row; the reader strips a curve it cannot draw and serves the figures beside it. That is a rollback rule as much as a rendering one: a code rollback does not roll back the database, so a release that widens the sampling must not be able to blank the whole column for the release before it.
Sort. On the rate change the cell prints, with one exception: a market with no headroom sorts ABOVE every measurable row. Its rate change is zero only because no additional borrowing can be absorbed at all, and ordering it on that zero would file it with the deepest markets on the page. A row with no reading sinks under either direction.
Fail-closed. A market is published only when the venue's own state reproduces the venue's own published borrow rate EXACTLY: the simulated current rate against Aave's or SparkLend's stored rate, the ported average against Morpho's borrowRateView, the ported curve against Fluid's stored rate at its last written utilization. Anything else is recorded with a health state (unsupported_model, parity_failed, stale_inputs, configuration_changed_revalidating) and the cell shows no data. A missing price, a stale snapshot (older than 12 hours) and a market never computed render identically. The rate model's configuration is re-read every run and fingerprinted — including a Fluid vault's own reward/fee overlay on smart debt, which cancels from the published change but decides which configuration a stored row belongs to; a change is recomputed and revalidated in the same run, published if it passes parity, and recorded as a structured diagnostic either way.
Not windowed, and no filter. The scenario is against the market as it stands, so the Trailing switch never moves it — subtracting a modelled stressed rate from a realized trailing figure would mix two bases. The column ships sortable with no filter of its own: a threshold would largely restate the sort while hiding the rows a reader most wants to see beside each other.
Why the estimator flips. For a floating leg the future path is unknown, the trailing 30d mean is a reasonable forward proxy, and today's spot is the noisy number. For a PT collateral leg the roles invert: the rate is deterministic at entry (you lock today's implied APY to maturity), so today's quote is not an estimate at all, it is the answer. Averaging it with ~29 stale daily entry quotes, none of which anyone can lock now, only injects error, and it lets a PT row outrank a genuinely better variable carry on the strength of quotes that have since disappeared. The funding leg of a PT carry still floats, so its 30d mean stays the right expectation: a term carry is fixed-vs-floating, "a rate locked today minus the funding it is expected to cost".
targetFixedToday = getTrailingImpliedApy(market, 1), the 24h-anchored mean of the market's implied APY (anchored at now(), not at the last snapshot).
The chat assistant renders the same headline figures (its carry cards read carry / maxLevCarry / volAdjCarry from the tool result, with headlineBasis naming the method and headlineTarget carrying the lockable fixed rate itself), so the screener and the assistant cannot quote different numbers for one strategy. The realised trailing-7d pair (carry7d, maxLevCarry7d) stays in the tool result as a backward-looking figure ("what did this pay last week") and is labelled as such wherever it is shown. Both are null whenever the trailing week did not measure a leg, and null there means "no figure", never "0%": the assistant says the week is not covered rather than quoting a spread against a leg nothing read.
Staleness rule: a stale quote is never served as current. If the pendle refresher has not written a snapshot in the last 24h the window is empty, targetFixedToday is null, and the row's Carry / Max-lev / Carry range cells render the NoData dash. They do not fall back to the 30d mean, which would silently reintroduce the defect in exactly the failure mode where it is worst. Funding shock, a scenario against the funding market rather than a reading of the collateral quote, keeps rendering; that asymmetry is deliberate. (This is also why the field exists at all instead of reusing the trailing 24h window: that window is anchored on the leg's own latest snapshot, so a refresher stalled for a week would still serve its last quote as "current".)
Max-leverage carry
maxLev = L · target − (L − 1) · funding, L = 1 / (1 − maxLTV)leverage = 1 / (1 - maxLtv), and the realised trailing-7d form of it is carryRealised7d in src/lib/data/carries-table.ts — one definition for the home page's rate tape and the assistant's maxLevCarry7d, null-preserving on both legs so neither can headline a trade whose funding the week did not read. maxLtv is the live on-chain max-LTV from the weekly vault-risk read (vault_risk_params), falling back to the registry vaultLTV. L is the maximum leverage a single-asset loop reaches by recursively redepositing borrowed funds at maxLTV.
Why L · target − (L − 1) · funding, not L · carry. Equity earns the full collateral yield on the whole levered position, while funding is paid only on the borrowed (L − 1) portion. Algebraically L·target − (L−1)·funding = L·carry + funding, materially higher than L·carry whenever funding is non-trivial (at L = 10 on a 5% funding leg, +5pp of ROE). This is also why a max-lev carry can stay positive even when the per-unit carry is slightly negative.
The collapsed table prints maxLevHeadline: the same formula, fed the estimators of the row's kind (see The collapsed row's headline above) so every headline cell on a row shares one methodology. Variable rows feed it the same-sample 30d means (targetMean30d / fundingMean30d); term rows feed it targetFixedToday against fundingMean30d. null when the 30d daily sample had fewer than 3 observations, or when a term row has no current fixed-rate quote.
Carry volatility & vol-adjusted carry
Computed in carryWindowStats (src/lib/data/carries-table.ts) over a daily sample of the carry path:
dailyLegSample(hist, windowDays)takes one observation per UTC day (each day's last 6h snapshot), valued by the 24h-trailing leg APYs (smartColApy24h − smartDebtApy24h), falling back to the 6h-spot pair only when the whole window predates the 24h columns.carryMean= arithmetic mean of the daily(col − debt)carries.carryVol= sample stddev (n − 1, unbiased), in pp of APY.fundingVol= the same sample stddev of the funding leg alone (deb). The dispersion a term (PT) position actually bears: its collateral rate is locked at entry, so only the funding leg floats under an open position.
Mean and vol are drawn from the identical sample, so mean(col − debt) ≡ mean(col) − mean(debt) by linearity and the vol-adjusted carry subtracts like from like.
Why a daily sample of 24h-trailing values. The wrapper legs (token_yield_apy) are already 24h-trailing, and the lending / fee legs all expose a 24h variant. Sampling those 24h values once per day makes the observations non-overlapping. The earlier 6h-cadence sample mixed 6h-spot lending legs with 24h-trailing wrapper legs, giving wrapper-heavy strategies ~75% window overlap between neighbours: their sample stddev understated true vol by ~2× and their Carry/vol read high relative to pure-lending strategies, making the column incomparable across strategy classes. Daily stride removes that bias in one move, at the cost of fewer observations — which is why the UI pins dispersion stats to the 30d window (a 7-observation 7d stddev is too thin to trust; carryVol30d / carryMean30d are emitted, 7d vol is deliberately not).
Method notes: no √T scaling (the observations are already annualised rates). VOL_FLOOR = 1e-12 snaps the float-cancellation residue of a flat sample (e.g. a stablecoin parked at its rate kink all month) to an exact 0: that window really did measure zero dispersion, and the residue would otherwise sqrt to ~1e-17.
Zero vol is a measurement; null is missing data. The two are kept distinct because the published statistic is a subtraction: a vol of 0 means no haircut (vol-adj carry ≡ the full carry), which is the best case for a carry and ranks at the top of the assistant's vol-adjusted ranking. Reporting a flat month as null instead withholds the figure, and because a ranking sinks nulls it buries the steadiest carries beneath every choppy one.
A zero reading must be earned
A zero is the one reading that ranks a row first, so it carries a burden of evidence the others don't: n >= 3 suffices to compute a stddev but not to assert that a rate does not move. Two independent things make that claim credible, and a zero is published only when both hold:
- Span — the sample covers at least half the requested window (≥ 15 days of the 30d window). A rate untouched for three days has demonstrated nothing.
- Coverage — the sample holds at least a quarter of the window's days in observations (≥ 8 of 30). Three readings a fortnight apart span the month but are blind between them: the rate could swing and return unseen, and calling that "never moved" reads a hole in the data as a fact about the market.
Failing either, the flatness is unproven, the vol is null, and the row dashes and sorts last. Both legs are needed because either alone inverts the rule's own principle — under a span-only test, 3 observations at days 0/15/29 would earn a full zero haircut while 14 consecutive identical readings, strictly better evidence, would dash.
The asymmetry against the moving case is deliberate: a thin sample that did move yields a positive stddev, i.e. a bigger haircut and a worse rank, which errs against the row. Only the optimistic claim needs a track record. The coverage floor is deliberately loose (a quarter, not near-daily) so that ordinary refresher gaps do not disqualify a genuinely steady market. Whether a dormant market is worth holding at all is a liquidity question the screener's capacity filters answer, not one for a rate-path statistic.
carryVol / fundingVol are therefore null when the statistic could not be measured: < 3 daily observations, a flat sample failing either evidence test above, or a non-finite stddev from a corrupt observation (which reads null rather than being coerced to a fabricated best case). Absent inputs are nulled by their own rules upstream, never by this floor: a term (PT) row with no fixed-rate quote in the last 24h still nulls its headline (see The collapsed row's headline) and still sorts last, however steady its funding leg was.
What the UI actually shows
Since 2026-08 (#669) this is no longer a /carries column. The collapsed table shows Carry range (30d) in its place — the band the daily carry sat in, with its distribution behind the mark beside it. Vol-adjusted carry survives on the surfaces that need ONE key to rank a list by: the assistant's list_carries, the chat carry card, and the carry catalog. Both read the same carryHeadlines pair, so the screener and the assistant still cannot disagree about the inputs. The rest of this section describes that surviving metric, and the carryVol30d / fundingVol30d statistics it and the expanded row's stability panel are built from, which are unchanged.
The Carry/vol ratio was retired from the collapsed table earlier. The metric prints Vol-adj. carry = the headline carry minus one sigma (a one-sigma certainty-equivalent haircut, in APY units; a thin choppy carry goes negative and renders red, which is the signal). The haircut subtracts like from like, so its basis follows the row's kind: a variable row is haircut by carryVol30d (both legs float), a term (PT) row by fundingVol30d alone, because a holder who locked the collateral rate at entry bears no collateral-quote vol. Haircutting a locked rate by the volatility of quotes the holder does not bear would penalise a PT row for a risk it does not run; the holder's real early-exit risk is mark-to-market duration loss, which a rate-quote stddev cannot express and which the oracle copy and the expanded charts handle separately. fundingVol30d inherits the VOL_FLOOR convention, so a funding leg that was float-flat all month (a stable parked at its kink) haircuts by zero and the strategy reports its full carry — the reading that puts the steadiest term carries at the top of the ranking instead of the bottom. It reports null only when the haircut basis is unmeasurable (see A zero reading must be earned) or the headline carry itself is missing. A Worst week path stat accompanies the raw vols in the assistant's carry detail.
- Worst week (
carryRiskStats): the lowest 7-consecutive-day mean of the daily carry over the trailing 90d, measured only on gapless calendar weeks (a window straddling a history hole is skipped — it would report a "week" no holder could have experienced). A tail/path statistic the symmetric stddev can't express; the panel labels the real sampled span (carryRiskSpanDays), so a two-week-old strategy reads "(14d)", not "(90d)".
The cumulative-return $1 backtest
The green equity curve on the carry chart's third panel (src/components/carries/CarryChart.tsx, plotData) is a buy-and-hold compounding of $1 of equity. Collateral and debt are two pots that compound independently from each side's realised growth factor; equity is their levered difference:
colCum *= (1 + smartColApy) ^ dtYears
debtCum *= (1 + smartDebtApy) ^ dtYears
cumReturn = L · colCum − (L − 1) · debtCum (floored at 0)dtYears is the actual elapsed time since the previous kept point, not a fixed per-period constant. In the clean, gap-free case it equals the source cadence (1 day on the 6M/1Y/YTD daily views, 6h on shorter views); when a snapshot is missing, a 2-day gap compounds two days of growth instead of one, so the curve stays time-true instead of compressing. This is the same model as the trade simulator's gross figures (grownLeveragedPosition in src/lib/sim/leveraged-position.ts) — single source of truth, so the green curve and the headline RETURN agree at every horizon. The /api/sim/swap-cost route does not use it: execution cost is a round-trip price at entry size (see below), so it needs no growth path.
Execution costs (the trade simulator's one cost row)
/api/sim/swap-cost prices the swaps the LEVERAGE LOOP forces, and nothing else. Depositing equity is not a cost of the leverage, so the figure goes to $0 at 1x.
Both legs are priced at the same notional: the debt borrowed at entry, (L-1) x notional. Entry sells that borrowed funding into the collateral; exit sells the collateral back. Each leg is quoted as a concurrent reciprocal round trip, so one cost fraction covers both directions, and the published figure is the round-trip price at today's depth.
The exit leg used to be sized at the grown debt (entry debt plus the funding accrued over the holding period). That made the cost row drift upward with the holding period for a reason that is not execution: the extra notional was accrued interest, which gross P&L already charges. Sizing both legs at entry keeps the row answering one question, what the round trip costs, and leaves the accrual where it belongs.
holdingDays is still used: it decides the to-maturity branch (a PT held to redemption is not sold on the market, so its exit is the redeemed proceeds swapped into the funding asset). targetApyPct and fundingApyPct are validated and then referenced nowhere, kept only so the request shape does not change. None of the three sizes any notional.
What this knowingly omits. The position actually unwound IS larger than the one opened, because the borrowed leg accrues while the trade is on, and the trader really does pay spread and impact on that extra size. Gross P&L charges the interest; nothing charges the cost of trading it. The omission is bounded by (1 + funding)^t - 1 applied to the exit leg alone, and it does not scale with leverage: at 5% funding over three months it understates that leg by ~1.2% and the total row by roughly half that. The trade is deliberate. The row stays a hold-invariant "what does this round trip cost today" figure rather than one that drifts with a holding period the reader is still choosing.
The sizing is pinned by src/app/api/sim/swap-cost/route.test.ts against roundTripNotionals, which takes neither a holding period nor a funding rate, so restoring the drift requires changing its signature. The merged row's shape is pinned in tests/e2e/carries.spec.ts.
The UI shows the sum as a single Execution costs row; the per-leg breakdown stays in the route's swaps[]. Net P&L is gross minus that sum.
Realised vs quoted: the cum-return basis
For Fluid smart-col / smart-debt strategies (T2 / T3 / T4) the equity curve above is not the whole truth, and the panel says which basis it is on with a REALIZED / QUOTED RATES chip, sitting beside the window's dates in the cum-return panel header. The explanation lives in a tooltip on that chip, not in prose above the chart (the earlier version stacked up to three lines of methodology directly above the curve, which buried it). The chip flips with the selected window (the gate below runs on the filtered series), so hovering it always describes the window on screen. Term carries get the same treatment on their PT ENTRY = WINDOW START chip.
The REALIZED tooltip carries two things: the method, then the basis reconciliation for the window on screen, computed by the basisGap memo and appended at render time:
Over this window the quoted basis returns +0.52% and the realized basis +2.09%. The +156bps difference is the pool's centre-displacement mark at 20.0x.
Do not drop that second paragraph. It is the only thing that reconciles the quoted headline carry (and the quoted max-lev APY) against a realised curve that can end lower, or negative; without it a reader sees a positive carry above a curve ending under $1 and assumes one of the two numbers is broken. The residual IS the product.
The two paths must be seeded identically or the residual is an artefact.
equityGrowthPathhas no predecessor for its first point, so it seeds that point with a wholenominalStepYearsof growth.realizedEquityGrowthPathseeds its first point at factor 1 (a position opens at $1; nothing has accrued yet). Handed the same points, quoted therefore compounds N intervals against realised's N−1, and the difference of two different holding windows is not a displacement.basisGapForpasses 0 as the quoted path's seed so both open at $1. On a synthetic pool whose per-share content grows at exactly the quoted APYs (true displacement 0.00 by construction) the un-aligned form reported −6.51bps at 16.79x on the daily views and −1.55bps on the 6h views, always signed against the carry — ~13% of the −49bp reference gap above, and enough to flip the sign of a small one. The plotted curve keeps its own seeding; only the reconciliation aligns. Guarded bysrc/components/carries/CarryChart.basis.test.ts.
The copy says the gap is chiefly the displacement mark, not that it is one. The per-share ratio is ground truth for what the pot did, while smartColApy / smartDebtApy are published estimates, so any tracking error between the two also lands in the residual. Displacement dominates it (see the verified window above); it does not exhaust it.
A smart leg does not hold tokens, it holds DEX shares, and the token content of one share is a state function of where the pool's internal price sits versus its centre. So a share's value moves with three things:
per-share value = trading fees + lending-layer interest + DISPLACEMENT
└────────── quoted APYs see these ───────┘ └── they cannot ──┘Displacement is a mark, not a carry, so it correctly has no place in an APY quote — but it lands in a held position's equity all the same, and leverage multiplies it. Verified on Fluid vault 0xdce0…0d9d (WBTC-cbBTC T4, NFT 18348, 2026-06-24 → 2026-07-12): the quoted-rate backtest predicted +0.45% on equity at 16.79x while the position actually realised −0.05%. Per unit of collateral the window decomposes as fees +2.36bp, lending interest +0.28bp, displacement −1.50bp, with a +1.52bp mirror on the debt side. Both signs hurt equity (the collateral pot shrinks and the debt pot grows), which is why 16.79 × −1.50bp − 15.79 × +1.52bp ≈ −49bp accounts for essentially the entire gap. The fee estimate was never the problem.
The realised series. fluid_dex_apy persists the per-share pot content at every 6h snapshot (migration 047), and the history readers value it at redemption rates in the strategy's accounting unit:
rate_i = token_yield_apy.share_rate for token i, else 1 (par asset)
colPerShare = t0_per_supply_share/10^dec0 · rate_0 + t1_per_supply_share/10^dec1 · rate_1
debtPerShare = t0_per_borrow_share/10^dec0 · rate_0 + t1_per_borrow_share/10^dec1 · rate_1realizedEquityGrowthPath (src/lib/sim/leveraged-position.ts) then compounds each leg from the ratio of its own per-share series instead of (1+APY)^dt. Only the SMART leg of a strategy has such a series: T4 has both, T2 only the collateral leg, T3 only the debt leg. The other leg keeps riding its quoted APY inside the same function, so a mixed strategy still produces one coherent equity curve. Redemption-priced (not market-priced) keeps the series on the same price-neutral basis as the quoted backtest beside it: it answers "what did the pot earn", not "what did the pair do".
The fallback rule. Realised mode engages only when every point in the window has a reading for every smart leg the strategy has. Otherwise the whole window falls back to the quoted path — there is no hybrid splicing, because a curve that is realised for one stretch and quoted for another is neither. The gate is the operative rule; the flat carry-forward of a NULL inside realizedEquityGrowthPath is a defensive backstop that the gate makes unreachable, and it exists so no gap can ever produce a zero or a NaN (M9).
A window fails the gate for any of these reasons, and the UI does not claim to know which: the window reaches back before the DEX resolver's deployment (Dec 2025, so 1Y views on long-lived pools read QUOTED RATES while 7D/1M/6M/YTD read REALIZED); the prod backfill has not run yet; a single 6h getDexState read failed; or a pot reads ZERO.
A zero pot is a reading, not a valuation. When nobody has taken up one side of a pool,
getDexStatereturnstoken{0,1}Per{Supply,Borrow}Share = 0— a true reading, so it is stored as 0 and NOT as NULL. But there is no per-share content to take a ratio of, sorealizedPerSharenulls that leg. This is load-bearing: a 0 would pass a!= nullgate and then compound the leg flat, which on a T4 whose debt pot reads 0 would render a confidentREALIZEDcurve with the entire funding cost deleted at up to ~17x leverage. ETH-osETH and reUSD-USDT carry zero SUPPLY pots on part of their history today.
The par-marking assumption (and what it costs)
The realised pot is valued c0 · rate_0 + c1 · rate_1, and a par asset takes rate = 1. That assumption is doing real work, because the pot's composition rotates: over the golden window WBTC-cbBTC's supply pot went from roughly (0.86 WBTC, 1.16 cbBTC) to (0.28 WBTC, 1.74 cbBTC) — a ~28pp rotation of a 2.02-unit pot — while its par-marked sum moved only +1bp. Valuing a rotating composition requires a relative price, so the reported number is a lever on the one we assume:
d(window equity %) / d(rate_0) ≈ −925 % per unit at 16.79x
⇒ a 1bp error in the assumed WBTC:cbBTC ratio moves the headline ~9bpPar is the right default here and is the basis the on-chain ground truth was measured on: it isolates what the pot earned in units from what the two tokens did against each other, which is exactly the price-neutral basis the quoted backtest beside it uses (the quoted path carries no basis term at all). But the consequence must be stated: if the pool's two tokens genuinely depeg, the realised curve is wrong by the rotation times the depeg, amplified by leverage, and no gate trips. A WBTC-style 50bp scare would put a 16.79x curve several hundred bps out. onchain_credit.token_basis already tracks market-vs-redemption per token and is the natural input for a future basis-aware mark; it is not wired into this series yet.
The window label beside the chip
rangeLabelFor (CarryChart.tsx) prints the window's first and last plotted date next to the basis chip. The year appears only when the two ends fall in different calendar years. That is a correctness rule, not a cosmetic one: the label formats month + day, so a 1Y window used to render JUL 14 → JUL 14, the same month-day twelve months apart, which reads as a zero-length range sitting directly beside the chip explaining how that window was priced. Same-year windows keep the shorter JAN 12 → JUL 14 form. Formatting is UTC-pinned, so a late snapshot cannot slip a day against the x-axis. Guarded by src/components/carries/CarryChart.range.test.ts.
Why the APY panels stay quoted
The APY panel and the carry-differential panel stay quoted and are unchanged: the headline carry is still "what rate was on offer", which is the right question for a screener. The realised curve answers a different one: "what would holding it have actually done".
Pool price vs centre (the retired displacement stat)
The expanded row used to carry a one-line readout above the chart, latest reading plus a 30-day range, from the CarryHistoryPoint series the chart already fetched:
POOL PRICE VS CENTRE = (dex_price / dex_center_price − 1) × 1e4 [basis points]It was removed in 2026-07 and should not be reinstated without a decision to. Displacement is not a figure an analyst acts on directly, it is the reason the realised basis exists, and the realised curve already prices it in the only place it lands: a held position's equity. dex_price / dex_center_price remain on CarryHistoryPoint (no query changed); they simply have no UI reader today.
The concept is still live in the copy, though, so the warning below still binds: the REALIZED / QUOTED tooltips both name the pool's drift from its centre.
What the removal genuinely costs, and is not recoverable from the tooltip. The strip was the only surface for the displacement level and its 30-day range. The tooltip's gapBps is a backward-looking, leverage-scaled return over the selected window; you cannot invert it to recover where the pool sits right now. So two things have no surface at all today: (a) the sign/persistence structure (a centre can sit away from the market for months, so entering at +16bps and exiting at +0.2bps is a loss taken with a perfect carry), and (b) the entry-timing question "am I entering at a displaced price?". If that question comes back, it wants a purpose-built answer, not the raw strip.
Do NOT describe this as reverting to the centre. A centre is not always the price a pool trades around. WBTC-cbBTC's is pinned at 0.99850 while the pool tracks the market (~0.9998), so its displacement is persistently positive (the 30-day range reads roughly +0.2bps to +16bps and never goes negative) — an analyst told to expect reversion to the centre would be underwriting a move that does not come. What unwinds the mark is the pool price returning to where the position entered, not to the centre. Earlier drafts of this copy (and of plan 011) said "mean-reverting mark"; that was corrected after reading the live centre.
Verified centre behaviour: WBTC-cbBTC's centre is FIXED at 0.99850, while weETH-ETH and wstETH-ETH centres TRACK the LST exchange rate (~+2.5%/yr). Both are handled identically by the valuation, because the realised series is priced at redemption rates rather than against the centre.
Why it differs from a mark-to-market NFT-PnL
The $1 curve is a yield-only, price-neutral backtest: both legs are valued at their on-chain redemption/exchange rate, and (1 + APY)^dtYears compounds each leg's realised yield. It deliberately omits secondary-market price moves of the legs — for same-asset (correlated) loops, market moves cancel inside the position anyway. A mark-to-market NFT-PnL (a per-position monitor, e.g. a Fluid-NFT time-weighted-return) marks the position to current oracle/market value tick-by-tick and reflects rebalancing, entry/exit basis, and any depeg.
For single-token same-asset legs the two nearly coincide; the residual collapses to annualised-APY-vs-realised-index plus the input source, and the wrapper basis only matters on a depeg. The backtest is the right tool for "what would holding this spread have earned"; the mark-to-market method is the right tool for monitoring a live position, and is reserved for that. The DEX-fee leg is realised in the backtest (via fluid_dex_apy), so it is not missing income — it is missing only price/path effects.
Corrected 2026-07 for smart pools. The "they nearly coincide" claim does NOT extend to Fluid smart-col / smart-debt legs, and the earlier framing that DEX rebalancing "only matters on a depeg" was wrong. A smart leg's units-per-share drifts with the pool's displacement from its centre with no depeg anywhere in sight, and at 16.79x that term was worth ~−49bp of equity over a three-week window. That is exactly the gap the REALIZED basis above closes; the quoted curve is retained as the fallback and as the screener's carry, not as a claim about what a held smart position earned.
Axis: symlog
The chart's LOG mode uses a symlog (asinh) transform, not a plain log, so 0 stays on-axis and negatives are representable (symlogFwd(v) = asinh(v · 100), SYMLOG_K = 100, so ~1% APY ≈ 1 unit: small ranges read near-linear and big spikes compress). The equity curve itself is always plotted linearly; symlog applies to the APY and differential panels.
vs-SOFR spread
SOFR is the risk-free benchmark. Data comes from the NY Fed reference-rates API via scripts/refreshers/sofr-rates.ts (cron refresh-sofr.ts, weekdays 13:00 UTC) into onchain_credit.sofr_rates. We do not compute the averages — avg_30d, avg_90d, avg_180d and the compounding sofr_index are pulled straight from the NY Fed SOFRAI (SOFR Averages and Index) endpoint. Values are stored in percent (e.g. 4.31 = 4.31%), per the NY Fed convention; the reader (src/lib/data/sofr.ts, getSofrSeries) returns them as-is and the caller decides on conversion.
The published averages are on a different basis, and are restated before use
The NY Fed publishes SOFR and its 30 / 90 / 180-day averages the money-market way: interest accrues over the actual number of calendar days on a 360-day year, and the average is annualised with the simple factor 360/days. Every rate this site quotes is an annual effective yield instead — a realised index ratio compounded over a 365-day year (annualizeRatio). The two are not the same number, so subtracting a published average from a supply APY overstates every pickup over cash.
The restatement inverts the Fed's own definition rather than approximating it. A published average a (percent) over d calendar days is an exact statement of how much cash grew: growth = 1 + (a/100) · d/360. Compounding that same growth on the site's basis gives
APY_equiv = ((1 + (a/100) · d/360) ^ (365/d) − 1) · 100sofrApyEquiv (src/lib/data/sofr-basis.ts, re-exported by sofr.ts for server callers). It is always upward and grows with the level: on the 30-day average, about 8 bps at 3%, 11 bps at today's ~3.6%, 19 bps at 5% and 26 bps at 6%. At today's level a market whose pickup over the published average is under about 11 bps therefore reads as negative once the benchmark is restated: that is the two figures being put on one basis, not a change in the market. The conversion is applied once, at read/display; storage and the refresher keep the published figure, and the tenor passed must be the average's own (30 for avg30d) — reading a 30-day average as a 90-day one costs another 1.1 bps at 3.6%.
sofr-basis.ts is deliberately import-free so client components can use the same conversion the server does; a value import from sofr.ts would drag the database driver into the browser bundle.
Which average is current, and when the comparison is withheld
The reader walks BACKWARD to the latest populated 30-day average rather than reading the newest row. The daily rate and the averages come from two separately fetched feeds, so an averages outage leaves a tail of rows carrying a rate and no average, and treating that tail as "no average" would blank the cash comparison everywhere while the database still holds the answer.
The walk is ceilinged at 4 calendar days, measured against the wall clock. Four days clears the publication calendar at its longest (the averages publish on New York business days, so a Friday figure is still the newest one a reader can have on the Tuesday after a Monday holiday); anything past that is an outage, not a weekend, and every surface withholds the comparison instead of printing a spread nobody can act on. The ceiling is wall-clock rather than relative to the series' own newest row because a row-relative rule only notices one feed falling behind the other inside the same table: when the job that writes the table stops, the series freezes together, the measured gap stays zero, and last month's cash rate keeps being printed beside supply APYs another job is still updating. What a reader compares a live rate against is today.
Where the benchmark surfaces
Vs SOFR column (
/repo-lending): the market's trailing supply APY minus the 30-day average restated onto the APY basis. It falls back to the latest published average within the staleness ceiling rather than blanking when the newest row carries none, and blanks past it. The header explains what SOFR is and names the 30-day average it subtracts; the day-count conversion behind the figure is deliberately NOT in the copy (it is how the comparison is made fair, not a decision the reader takes) and lives insofr-basis.ts.Spread, in bps (home rate tape,
src/lib/data/home-metrics.ts): the same converted figure,round((bestSupply − sofrFrac) · 10000). The tape's own SOFR item is labelled "SOFR (30d avg, APY basis)" and shows that same converted level, so the level and the spread beside it are on one basis, and the label discloses the conversion where a marquee has no room for a tooltip.Rates chart benchmark line (
/repo-lending): the converted 30-day average, named "SOFR (30d avg)" in the legend and the tooltip. It stays the 30-day average at every APY window (24h / 7d / 30d) — the tooltip says so — and it breaks only after more than 7 calendar days with nothing published, so ordinary weekend and holiday blanks bridge.The assistant quotes the same restated figure and is told that none of the published ACT/360 values may be subtracted from a site APY, so a chat answer and the column agree.
Realised-return benchmark (the multi-strategy-funds peer chart): uses the NY Fed SOFR Index itself (
getSofrIndexRows) —index(end) / index(start)is the exact realised return of cash earning daily SOFR over a window, so it aligns to a strategy's snapshots as a true risk-free curve. This path needs no restatement: the index is a growth factor, not an annualised rate. The index isnullbefore 2020-03 (when the NY Fed began publishing it); those rows are filtered out.Benchmark spread column (
/multi-strategy-funds, the right-edge pill): the fund's APY over the selected window minus the same window's benchmark return, in bps. It runs off the SAME realised-return index as the bullet above, put throughwindowApyfor each of the three windows, so no restatement applies here either — both sides are the realised growth of an index annualised by actual elapsed time.The benchmark follows the ASSET switch, and this is not cosmetic. The USD view prices funds against SOFR ("Vs SOFR", matching repo lending); the ETH view prices them against the wstETH staking baseline ("Vs wstETH"). An ETH-denominated fund's return is denominated in ether, so subtracting a dollar cash rate from it produces a number with no economic meaning; what an ether allocator is choosing between is the fund and passive staking. The two baselines are the same pair the peer chart below the table draws as its dashed line (
baselineByDenom), so the column and the chart cannot disagree.Dashes rather than reads zero when either side is unmeasurable, and both sides are measured to the same date: the baseline is truncated to the funds' newest reading before its window is taken, since the SOFR index publishes on its own cadence and generally runs past them.
The baseline is withheld when it trails the funds by more than
SOFR_STALE_HARD_MS, the ceiling/repo-lendingapplies to the same-named column. It is measured against the funds' newest reading rather than the wall clock: what misleads is one side of a subtraction ageing out while the other keeps moving, whereas a whole-pipeline stall leaves the spread true and is already declared by the panel's "last updated" stamp.A short window can turn a documented 0.00% into a deep negative spread. These funds are priced by oracles on their own schedule, so a 24h or 7d window can legitimately span no price update and the APY column reads exactly 0.00% (see the note above). Subtracting a live benchmark from that zero renders as a strongly negative red pill, which reads as a verdict the data does not support. The ETH benchmark tooltip carries that caveat; it was removed from the USD benchmark and from the APY column at the product owner's direction, so on the default view the state is currently unexplained.
Peer performance chart (/multi-strategy-funds)
Always on below the table, the way the rates chart sits below the repo-lending rows, and fed the FILTERED rows so it can never plot a fund the table is hiding. One line per fund: the realised return of a deposit made at the window start, rate(t) / rate(start) − 1, off one reading per UTC day. Every line is indexed to 0% on the same date, which is what makes the panel a comparison rather than three separately-scaled curves.
The window. Timeframe pills 3M (default) / 6M / 1Y / ALL, anchored on the newest fund reading rather than the wall clock, so a stalled refresher shortens nothing and the fixture stays deterministic.
ALL is the SECOND-earliest first reading among the funds on screen, not the earliest and not the latest. Both alternatives are worse, and the reasoning is the load-bearing part:
- Earliest reading anyone has sets the start at the oldest fund's first day and then the exclusion rule below hides every other fund, leaving one line on a panel whose purpose is comparison. It is also knife-edge: one line or four turns on whether two funds were first read on the same day.
- Latest first reading, so nobody is hidden is what the head-to-head chart this replaced used, and it breaks the ladder. The window it picks is dictated by the youngest fund, which today lands inside six months on both denominations, so ALL would render a NARROWER window than the 6M pill above it and clicking further down the control would show less history.
The second-earliest is the longest window on which a comparison is still possible: at least two funds always clear it, and whenever a fixed pill draws two funds those two reach back past its start, so ALL reaches at least as far as that pill. The control then reads as one sentence: longer window, fewer funds, and ALL is the longest one that still compares anything.
The exclusion rule. A fund is drawn only when its record starts at or before the window and ends after it. A fund whose record begins mid-window cannot be indexed to the same point as the rest, and indexing it to its own start instead would put two lines on one panel measuring different periods. Excluded funds are named in the legend, struck through with no record before <date>: a fund that simply vanishes when the reader lengthens the timeframe reads as a verdict on the fund. The legend is the whole of that disclosure (the footnote that repeated it was removed), so its e2e assertion is what holds it. That assertion now checks the fund is NAMED, not merely that the phrase appears somewhere on the page. The copy says "performance record", never "did not exist" — creddit's record starts when tracking began, which for several funds is later than the inception date their own statistics tower prints.
The benchmark (SOFR realized-return index for dollars, wstETH staking for ether) is drawn dashed and is NOT subject to the exclusion rule: it is the yardstick the comparison is set against, not a peer competing in it, and its index predates every fund. It is subject to two rules instead. It takes the same base date as the funds, and is dropped entirely rather than re-based when it has no reading at or before the window start. And it is clipped to the funds' newest reading: the SOFR index publishes past the funds' last snapshot, and a dashed line running weeks beyond where every fund stops both leaves the reader comparing end-points measured over different periods and reads as the funds having stalled.
Fund lines break on a missing day (connectNulls={false}): a fund reads every 6h, so a gap is a refresher outage and bridging it would draw a run of accrual nobody observed. The benchmark bridges its own gaps, because a weekend is its publication calendar rather than missing data.
The ETH view compares staked ether to ether at parity, and this is an assumption, not an identity. Funds quoted in staked ether are indexed and compared here as though one staked ether is one ether; a depeg would widen every gap on the panel in ether terms, on a chart carried to two decimals. The footnote that disclosed this below the chart was removed at the product owner's direction along with the rest of that block, and nothing on the page replaced it, so the assumption is currently recorded here only. The same caveat applies to the "Vs wstETH" baseline, which is a staking index priced the same way.
The window rule, the exclusion rule and the indexing are pure functions (peerWindow, returnsFrom, dailyCloses in PeerReturnsChart.tsx) with unit coverage, because none of it is visible in a browser: a chart that quietly indexed one fund to its own start draws the same lines in the same colours over the same axis, and only the values move.
Borrowable / capacity
Panel semantics differ by venue. On Fluid rows, "Total supplied" / "Total borrowed" are one vault's two sides, so the tooltip's borrowed-over-supplied utilization is a real vault figure. Aave/Spark have no vaults: the same columns hold PROTOCOL-WIDE totals of two unrelated reserves (the collateral token's whole supply, and the debt token's whole borrow book across every collateral), so those rows are labeled "Reserve supplied" / "Reserve borrowed", the tooltips say pool-wide, and the ratio shown is the DEBT reserve's own utilization instead: (total_deposited − available_liquidity) / total_deposited from the latest per-token aave_v3_reserve_apy / sparklend_reserve_apy snapshot (getDebtMarketUtilization, ≤2 days stale or omitted) — the share of the reserve's deposits that is currently deployed, which is what drives the borrow rate and entry headroom. Read it as deployed, not as borrowed: since available_liquidity is the cash a borrower can actually draw, the numerator also carries debt the reserve has written off and treasury accrual it has not minted. On Aave's WETH reserve at one block that reads 82.45% deployed against Aave's own 82.01% borrowed — close, and above the borrowed share wherever a reserve carries a deficit; a derivation that instead subtracts debt from aToken supply gives 80.01% and sits below both. The copy beside the figure says "deployed" for exactly this reason. A cross-reserve borrowed/supplied ratio is never shown (it once rendered 4,854% on a PT row). Morpho Blue rows keep the "Total supplied" / "Total borrowed" labels but mean a third thing again: the market's posted collateral vs its loan-token borrows (see below), so their ratio is an aggregate LTV, not a utilization, and is likewise never shown — the loan-side utilization is shown instead.
Read from onchain_credit.vault_capacity (src/lib/data/vault-capacity.ts), populated every 6h by the vault-capacity refresher (cron 15 */6, see Data Pipeline). All cap/current values are stored in whole tokens; the UI computes headroom and the USD figure. The key column is borrowDynamicCap, the operable cap such that borrowDynamicCap − borrowCurrent is the amount borrowable right now.
| Venue / shape | borrowDynamicCap is… | Headroom (USD) |
|---|---|---|
| Aave v3 / SparkLend | the static borrowCap (hard ceiling; 0 = unlimited per Aave) | lower of three (see below) |
| Single-token Fluid (T1/T2) | borrowCurrent + Fluid on-chain borrowable`` | (dynamicCap − current) × price |
| Smart-debt Fluid (T3/T4) | per-token remaining at current pool composition | Σ legRemaining × legPrice |
| Morpho Blue | the market's loan-token deposits (totalSupplyAssets) | (deposits − borrows) × price |
Why Aave/Spark Borrowable is the lower of three. Borrowing on Aave/Spark requires posting collateral, so the headroom shown is the lower of: (1) the borrow-cap headroom (borrowCap − borrowCurrent); (2) the borrow implied by the collateral's supply-cap headroom, (supplyCap − supplyCurrent) × collateralPrice × maxLtv — the collateral's supply cap (dynamic on Spark via its CapAutomator) limits how much more collateral you can post, hence how much you can borrow against it; and (3) the debt asset's available pool liquidity — the newest available_liquidity on the reserve's own aave_v3_reserve_apy / sparklend_reserve_apy snapshot, in TOKEN units (src/lib/data/market-liquidity.ts). That column is the amount the pool will actually release at the snapshot block, not aToken supply minus debt, so this term does not count a reserve deficit as borrowable — on the WETH-debt carries that alone is ~$99M of headroom that is not really there — and it is absent rather than zero when the read failed. The map is keyed by reserve address and covers every tracked reserve, so the term applies to exotic debt assets (USDe / USDtb / GHO) and to WETH debt, not only to the four pooled stablecoins; the caller multiplies the token amount by the debt asset's USD price. A cap of 0 (unlimited) or a missing price/liquidity drops that term. Computed on the carry detail (carries/page.tsx) where max-LTV + prices are in scope. This replaced a borrow-cap-only headroom that overstated borrowable when the collateral supply cap or pool liquidity was the binding constraint (e.g. sUSDe/USDC on Aave was showing the ~$361M USDC borrow-cap headroom rather than the ~$78M the sUSDe supply cap actually permits).
Why Aave's GHO reserve is not a repo market
Aave carries a GHO reserve, and Repo lending deliberately does not list it. Two properties, both read on-chain on 2026-07-27, put it outside what this page ranks:
- Its reserve factor is 10000 bps (100%). Every basis point borrowers pay is routed to the Aave DAO treasury and none reaches a supplier, so the reserve's
liquidityIndexhad not moved off its 1.0 (1e27) starting value across any snapshot up to that date. - Its rate curve is flat (
variableRateSlope1 = variableRateSlope2 = 0), so the borrow rate is set by governance and does not respond to utilization. Nothing here is cleared by a market.
The reserve does not mint on borrow: aEthGHO is not a GHO facilitator, and the book is supplied by a DAO facilitator. Its deposits are therefore one governance-owned position rather than third-party lending.
Whether it mints or not, there is no lender to rank and no rate to rank them by. The two properties disqualify together, not separately: a flat curve on its own only costs a reserve its published target utilization, and such a reserve stays listed (see the rule below). It is the 100% reserve factor, no supplier to pay, that makes this one not a market. Listing it would put this table's columns to work saying things they do not mean: Total Deposited reading as many lenders' capital when it is one holder's float, and the Pooled market type promising loss-sharing among suppliers the reserve does not have.
Both properties are governance parameters, not invariants, and the readings above are dated for that reason. If Aave lowered that reserve factor, the reserve would become a real lending market and would belong back on this page. Nothing currently watches for that: the exclusion is a maintained decision rather than a self-checking one, which is a known gap.
A GHO holder earns through the savings vault sGHO, which is covered on Asset profiles.
The reserve's borrow rate still matters as a borrower's funding cost on Carry trades, and still reaches that page, but not as a standing feed. GHO is not in the always-tracked reserve list, so a snapshot is written only while an active Aave v3 carry has GHO on one of its legs. The write and the read key off that same registry, so a carry that needs the rate always has it; what does not follow is an unbroken history, which gaps whenever no such carry is listed.
Target utilization is suppressed on such a reserve too, by a separate and more general rule: getTargetUtilization returns null for any Aave-family reserve whose rate model has variableRateSlope1 == variableRateSlope2 == 0. Such a reserve prices flat at every utilization, so its optimalUsageRatio is an inert field rather than a kink, and publishing it under a column whose tooltip promises the rate steepens there would describe a mechanism the reserve does not have. The rule covers SparkLend's older rate-strategy getters as well as Aave's current ones, so a flat Spark reserve (sDAI and GNO today; no rendered stablecoin row moves) shows current utilization only. The strategy pointer is also cached no longer than the value behind it, so a published target is at most one refresher window stale rather than a day.
The two Aave-family interfaces are disjoint, which is why trying one and falling back to the other is safe: Aave's v3.1+ shared strategy takes the asset as an argument on every getter and has no no-arg ones, while SparkLend's per-reserve v3.0 strategy has only no-arg getters and no getInterestRateData at all (verified against aave-v3-origin and aave-v3-core). A reserve answers on exactly one, and the strategy is resolved through the addresses provider at read time, so a governance swap from one family to the other is picked up without a code change. A reserve on some third, bespoke interface answers neither and reports no target rather than a guessed one.
Target utilization on the other two venues
The target is the level a rate model steers toward, so what it means is a property of the model — and on the isolated venues the model is not a property of the venue.
Morpho publishes 90% only for a market that actually runs the model that number belongs to. TARGET_UTILIZATION = 0.9e18 is a hardcoded constant of the AdaptiveCurve IRM contract, and a market's irm is one of the five immutable parameters that ARE the market: any address can be passed at creation, and Morpho itself imposes nothing. So the target is gated on the market's own IRM, carried on its registry row and on its carry config. A market on any other rate model, or one whose IRM we could not read, shows current utilization alone rather than a constant borrowed from a contract it does not use. The admission rule already keeps such a market off both surfaces, and the sync now also raises an alert naming it (see docs/data-pipeline.md), because a rate model nobody here has read is a decision for a human, not a silent exclusion.
Fluid sets its borrow curve per token at the shared Liquidity Layer, and the utilization that curve runs on aggregates every Fluid product borrowing that token (vaults, DEX, lending). An individual vault's own utilization drives no rate at all, so both halves of the pair shown for a Fluid leg are Liquidity-Layer figures for the BORROWED TOKEN: the current reading from the layer's accrued borrow/supply totals, and the target from that token's rate curve.
A Fluid token runs either a one-kink or a two-kink curve. On a two-kink token the slope is moderate between the first and second kink and severe past the second. The published target is the first kink: it is the level the curve steers toward and the first point at which borrowing stops being cheap, which is the conservative reading of a column that promises the rate steepens there. The second kink is read at the same time and kept rather than discarded, so a later escalation metric can tell "in the moderate zone" from "past the cliff" without a second read. Only the two layouts verified against Fluid's own contracts are decoded; a future resolver version reports no target rather than have its words read at the wrong offsets.
What a Fluid vault's borrowers actually pay differs from the layer rate wherever the vault applies a term of its own. A vault carries a
borrowRateMagnifier(1x = 10000) that scales the Liquidity-Layer rate into its own, and Fluid's rebalancers move it to route borrow rewards, so an incentivised vault can sit below 1x for as long as the incentive runs.Every published Fluid FUNDING figure is now the vault's own rate, read from the vault's own borrow index rather than reconstructed from the magnifier (
fluid_vault_rates, Trailing APY on a carry leg). That was the right way round: rewards and fees also reach a borrower throughrewardsOrFeeRateBorrow, so a vault at 1x is not automatically a vault paying the layer rate, and only the index carries every term by construction. The capacity refresher still records each vault's magnifier and alerts once when it leaves neutral, which is now notice of a change rather than notice of a gap.The COLLATERAL side is not re-based. A smart-collateral vault carries the identical packed sign-and-magnitude overlay on its supply side (
supplyRateMagnifier, decoded by the same branch of the resolver's_getExchangePricesAndRates), and a Fluid carry's target leg still reads the wrapper's own appreciation plus the layer's supply rate. So a supply-side incentive would flatter or understate the target leg for as long as it runs. The series recordssupply_apyandsupply_rate_magnifierper vault against that day, and the one-time alert fires on the supply side too, so it would be known rather than discovered. Every live vault is neutral on both sides today (normal collateral at 10000, smart collateral at a raw 1).
Distinguish this from an empty book: hasLiveBook drops a market whose current deposits are zero (Fluid lists USDS in its Liquidity Layer, but no Fluid vault borrows it, so both sides sit at zero). The test is current deposits, deliberately not the rate (which would drop a funded market that merely has no borrowers this window) and not the history (Fluid's USDS carries a year of all-zero snapshots). A failed size read leaves deposits null and is kept, since a missing number is not evidence of an empty market.
Why single-token Fluid uses current debt + on-chain borrowable, not limit − debt. Fluid's borrowLimit is a dynamic limit that expands over time (an expandPercent over an expandDuration). At any instant the raw borrowLimit overstates what you can borrow right now, because the limit is still expanding toward its max. The on-chain borrowable field is the amount actually available this block (matches the Fluid UI's "Borrowable"), so borrowDynamicCap = borrowCurrent + borrowable gives the true instant headroom. Fluid's borrowable is already min(vault, LL)-constrained, so it also respects the underlying Lending-Layer token's availability.
Why smart-debt is pool-constrained per-token, not summed LL ceilings. A T3/T4 debt is a DEX-LP position: you owe two tokens in the pool's current composition. The refresher persists, per leg, the remaining borrowable derived from Fluid's own ved.borrowable LP-share number (also min(LL, vault)) translated through the DEX resolver's per-share rates (smartDebtLegs, borrowToken{0,1}Remaining). Headroom is the sum of the two per-token remainders priced in USD — not the sum of each Lending-Layer ceiling, which would ignore the pool composition that actually binds. borrowableUsd is null (chip/panel suppressed) whenever a price or a per-leg remainder is missing, rather than under-counting.
Why Morpho Blue stores deposits in the cap columns. A Morpho market is an isolated pair: lenders deposit the loan token, borrowers post the collateral token (which is never lent out) and draw the loan token. There are no borrow or supply caps at the market level — caps live on MetaMorpho vaults, not on markets — so the only thing bounding a new borrow is the market's available liquidity. The refresher therefore writes the loan-token deposits into both borrow_cap and borrow_dynamic_cap, so the shared headroom math borrowDynamicCap − borrowCurrent comes out as deposits − borrows = available liquidity, which is exactly what Morpho's own UI shows as "Liquidity". Storing the deposits (rather than 0) also keeps the Aave 0 = "Unlimited" rendering path from ever firing on a Morpho row; the page guards that path explicitly too, so an empty market reads "0 USDC", not "Unlimited".
| Column | Morpho value | Source |
|---|---|---|
borrow_current | totalBorrowAssets / 10^loanDecimals | on-chain market(id) on the Blue singleton |
borrow_cap = borrow_dynamic_cap | totalSupplyAssets / 10^loanDecimals | same call; no protocol cap exists |
borrow_expand_* | NULL | no dynamic-cap mechanics (also suppresses the Fluid projection chart) |
supply_symbol / supply_decimals | collateral token | registry config (tooltip copy only) |
supply_current / supply_cap | NULL | collateral has no on-chain aggregate, and there is no cap |
total_supplied_usd | posted-collateral USD | Morpho Blue API state.collateralAssetsUsd, NULL on failure |
total_borrowed_usd | borrow_current × loan price | DefiLlama, NULL if unpriced |
The loan-side utilization in the "Total borrowed" tooltip is borrowCurrent / borrowCap — under this mapping, the share of the market's deposits that is borrowed. It is the exact analog of the Aave debt-reserve utilization, and the reason the borrowed-over-supplied ratio is suppressed on Morpho rows: those two figures are different assets on opposite sides of the market. Because Morpho markets carry no cap, a thin market's headroom can be small in absolute terms, so the expanded-row capacity warning panel (with its notify-me form) engages on Morpho rows like any other venue. The collapsed row no longer carries an inline warning triangle for tight capacity: pool depth is read straight off the dedicated Borrowable column (values below $100k render red), so the redundant glyph on the Position cell was dropped. MetaMorpho vaults can reallocate extra liquidity in through the Public Allocator, so effective depth can be somewhat larger than shown; the tooltip says so.
The refresher covers every active registry vault; a row renders "—" only when a read failed or a strategy is missing its capacity row. Auto-discovered Fluid strategies are keyed in the capacity map by vault address; Aave/Spark and Morpho by semantic strategy key — page.tsx falls back from strategy.key to the lower-cased vault address, except on Morpho, where every carry shares the Blue singleton as its vault address and the fallback would alias all of them onto one row.
The repo rates chart
The chart under the /repo-lending table plots exactly the markets the filters leave visible, on the trailing window the APY switch selects. Three drawing rules matter for reading it.
A gap is information, so gaps render as gaps. The x-axis is a uniform daily grid and a market's line BREAKS on days it has no reading, rather than being drawn straight through them as though the rate had held. A single reading surrounded by blank days renders as a dot. The cash benchmark is the one line that bridges, and only up to a week (see the vs-SOFR section above): its blanks are the publication calendar, not missing data.
The y-axis is not floored at zero, in either the linear or the log mode, so a negative supply APY — a market socialising bad debt pays its lenders less than nothing — is drawn where it happened instead of being clipped onto the baseline. The log ladder mirrors below zero for the same reason. The linear axis always carries a labelled 0% gridline, including in that drawdown case, which is exactly when the reader needs to see where zero is.
An empty panel says which kind of empty it is. A selection with no series at all reads "No history available yet."; one whose series exist but hold under two points inside the chosen timeframe reads "Not enough data in the selected window.", which is a statement about the window rather than the dataset. The legend row is hidden when there is nothing plotted.
Underwritten capital (Repo lending)
The panel under a /repo-lending market row answers one question: whose collateral stands behind this book. Three writers fill it, one per market structure — Fluid's vaults, Aave/Spark's account-level positions, Morpho's isolated markets — and they publish the same column meanings, so the panel reads the same way whichever venue the row belongs to.
A slice is debt, valued in dollars. exposure_usd is the deposit-asset debt this collateral backs, marked at the deposit asset's live price on every basis. The companion quantity is the unit that basis measures in: the borrowed stablecoin on Fluid, the attributed collateral on Aave/Spark (so quantity × collateral price is the slice's collateral value), the borrowed loan-token amount on Morpho.
Coverage is the attributed share of the book, on both live bases. A market's book is the venue's own borrowed total: the Liquidity Layer's total borrow for the stablecoin on Fluid, the reserve's borrowed total on Aave/Spark. Whatever the writer cannot attribute to a named, priced collateral — a borrower outside the vault set, an account the scan did not reach, a leg that failed to read, a collateral with no usable price — is published as an Unattributed slice once it clears 0.1% of the book, and coverage is attributed / book. So the slices sum to the book up to that floor, below which the remainder is rounding and is left unpublished rather than drawn as a wedge (live today Fluid's is ~$1.1k on a $162M USDC book); above it the donut shows the shortfall as its own wedge, and the "N% of book attributed" note under the panel is the same number both writers computed (it is suppressed once it rounds to 100.0%). Fluid coverage is not structurally 100 either: debt whose collateral cannot be priced goes to the remainder rather than being counted against a collateral whose value had to be dropped.
The overcollateralization hero divides attributed collateral by attributed debt, where attributed debt is summed from the non-Unattributed slices directly, excluding any slice whose collateral could not be valued. It is never the book scaled by a coverage fraction, which would not reconcile with the per-slice Overcoll. factor column beside it. wtd_liq_threshold is the debt-weighted liquidation threshold over that same attributed base; on Fluid it comes from each vault's configuration rather than from the tick scan, so a vault whose distance distribution could not be reconciled still contributes to the weighting.
One hero sentence pairs two conventions, deliberately. "Total X borrowed $A of $B supplied": A is marked at the deposit asset's live price, B counts one unit as one dollar — the same par convention the Total deposited column and the size series use. Pricing the supplied side here would desync it from the size and utilization figures on the same row, which is the worse incoherence; the depeg-honesty burden is carried by the borrowed side. On Fluid, A is the Liquidity Layer's whole book for that stablecoin, including borrowers outside the vaults the breakdown lists.
A Morpho slice pairs two vintages, and its ratio two price sources. The borrowed quantity is read at the snapshot block and marked at the loan token's current price, so in the worst tolerated case the two are about 12 hours apart; past that the row is not written at all rather than stamped with today's price. The Overcoll. factor then divides Morpho's own indexer collateral USD by that marked denominator, so one ratio carries two price sources. Both are worth knowing when reading the number, and both leave it a dollars-over-dollars comparison rather than a dollar figure over a raw token count.
Vintage rules, and what they do and do not guarantee. The panel is withheld entirely once a market's newest exposure snapshot is more than 24 hours old, and the expanded row says so in one line that covers both ways a breakdown can be absent — one appears with the next fresh snapshot, and one more than a day old is withheld — rather than implying the market is merely awaiting its first one. The reason for the bound: the borrowed total is paired in one sentence with a supplied figure from a table that keeps refreshing, so a stalled writer would otherwise render a utilization the market never had. Four refresher windows of slack means a missed run or two changes nothing. The guarantee has a precise shape, and it is worth stating exactly: it is a render-time bound on a page cached for 30 minutes, so a page prerendered just inside the cutoff keeps serving the panel until it next regenerates. The panel's As of <ts> UTC stamp, not the panel's absence, is the reader-facing vintage signal. The /portfolio holdings table applies the same 24-hour bound to its collateral cluster so the two surfaces cannot contradict each other, and each isolated Morpho market resolves its own latest snapshot, so a partial refresh blanks only the market it missed.
Separately, the risk half of the panel — the overcollateralization hero, its "$X posted" caption, and the stable-side leg of Available to borrow — is withheld when the risk row's stamp and the exposure snapshot differ by more than 5 minutes. Both writers stamp the two from one aligned window inside one transaction, so a healthy run has zero skew and the smallest divergence that can actually occur is a whole window: one leg left standing from an earlier run. The slices, donut, coverage note and as-of stamp are unaffected.
Capacity states what it knows. "Available to borrow" renders the unknown dash, never "no cap", when neither the collateral-side limit nor the stable-side stock resolved. The disclosure lives on the COLUMN explainer, not on the cell. The per-cell hovers that used to carry it were native title= attributes: no icon, no touch equivalent, and unreachable in practice, so they were removed. The column's own explainer now closes each venue's wording with the dash clause ("a dash means no limit could be read for that collateral in this snapshot, not that there is no room left"), which is the distinction the cell hover existed for: an unread limit and an absent one are pixel-identical in an amber capacity column otherwise. Isolated markets carry no explainer at all, by product decision: their figure is simply what lenders supplied and borrowers have not taken, which the column name already says.
When only the collateral side resolved, the figure is still an upper bound rather than drawable cash (capCells, the stableAvail == null branch), and nothing on screen now marks it as one: the per-cell hover that did is gone and the column explainer says the column is bounded by available liquidity without distinguishing the arm that bound it. Recorded here rather than papered over.
Where a limit IS known, the column explainer names it per venue: Fluid's dynamic vault borrow cap bounded by its shared liquidity, or an Aave/Spark collateral supply cap likewise bounded. And for Aave/Spark the column is growth-to-cap capacity — a full supply cap's borrowing power at the collateral's loan-to-value, less the debt already drawn against it, bounded by the pool's unborrowed cash or the room left under the stablecoin's borrow cap — so it is not additive down the column, nor across the other deposit assets the same collateral backs.
A collateral that can take no new debt renders $0, and the venue that got it there is no longer distinguished. Fluid arrives at it by winding a vault's borrow limit down to dust; Aave/Spark by taking the collateral's borrowing power to zero, or by freezing the reserve. Those were three separate sentences on the per-cell hover, which was a native title= and unreachable, so they went with it (capMethodSentence). All three now read as one $0 (capCells, the noNewDebt branch). The distinction is a real one and it is currently not surfaced anywhere.
Basis / peg (secondary-market exit risk)
src/lib/data/basis.ts reads onchain_credit.token_basis. The basis of a leg is:
basis = market_price / redemption_value − 1 (fractional)where redemption value is token_yield_apy.share_rate × numeraire price for yield wrappers (redemption: "share_rate") or just the numeraire price for at-par assets (redemption: "par", e.g. GHO/USDe/USDtb ~ $1, WBTC/cbBTC ~ 1 BTC). Market price comes from the Dune price mirror (token_price_bars, via the same-bar rule in data-pipeline; formerly DefiLlama). The panel reports the latest basis, the deepest adverse over 90d/1y, a p5–p95 band, and the share of the past year beyond ±0.5% in the adverse direction.
This module exists because the APY methodology reads on-chain redemption rates "with no DEX/market arb noise", so a market dislocation never shows up in carry or vol. Basis fills that gap. The sign is framed per side:
- Collateral (single or smart): you are long it; a discount is adverse (you sell cheap on exit). A smart-collateral pool services swaps as
supply(in)/withdraw(out), so a constituent depeg leaves the position holding the cheap asset — impaired. Adverse = the most-negative constituent. - Single-token debt: you are short exactly that token, so a discount is favorable (you buy it back cheaper to repay).
- Smart (LP) debt: not charted, but explained. A pool services debt as
repay(in)/borrow(out), so a single-constituent depeg rebalances the debt into the full-value asset and the dollar debt is unchanged. A single-leg depeg does not move a smart-debt position's exit cost, sodebtLegMap()maps these to the"smart-debt"doctrine (the key must still exist so an unmapped debt symbol surfaces in CI) and the Market Depth modal printsSMART_DEBT_NOTEin the side's slot rather than leaving it blank, the same pattern as the PT collateral note below. The exception the copy has to keep stating: a simultaneous slip in both constituents does lower what you owe, so the position is not unconditionally on the expensive side. - Pinned-basis-class legs: the class is RETIRED, and nothing is skipped any more (M19). Until September 2026 a wrapper tagged
basisClass: "pinned"(sUSDS was the only declared member) was treated as arbitrage-closed to its redemption value, so its constituent was skipped from thetoken_basisread entirely and the Market Depth modal rendered a pinned note in the chart's slot. #810 Y2 removed the tag, the skip and the note:BasisClasshas one value,market,readLegConstituentshas no pinned branch, and sUSDS is a charted basis series like every other wrapper — its own premium or discount against USDS finally draws instead of being assumed away. The reasoning that survives is why USDS itself is a tracked series: residual risk on a redeemable wrapper lives on the underlying's peg, so the underlying needs somewhere to be seen. AUSD joined for the same reason: it is a covered money-market asset that had no series at all.
The feed has to be finer than the thing being measured. A basis is a ratio of two numbers that are close together, so the market quote's precision is not a detail — it is the floor on what the metric can say. Dune's aggregated-exchange channel quotes some assets to the whole cent; on sUSDe (~$1.24) that is ~80bps, while the basis being measured lives in single-digit bps. Because sUSDe's redemption value accrues daily and the quote only moves on a cent tick, the stored series drifted down at the yield rate and snapped back on each tick — a sawtooth of pure quantisation, and the "largest 1-yr drawdown" the panel published for sUSDe was reading off it, not off the market. Such an asset is now priced from realised DEX trades instead (see data-pipeline), and its stored history was re-derived over the past year. The general rule this leaves behind: before trusting a basis series, check that the market feed resolves finer than the deviation being claimed.
On the debt side the distinction that decides debtLegMap() is whether the token is the unit of account or merely claims to track it. Only one token family is genuinely the former:
- Every dollar in the map (
GHO,USDe,USDtb, and the nativeUSDC/USDT) is charted, as is the one non-dollar single-token debt,wstETH. Owing a token that trades below par on the unwind is a real favorable exit tail: you buy the debt back cheaper than you sold it. Note "in the map":DAI,USDSandAUSDare par-trackedbasisTokens()with the same argument behind them and nodebtLegMap()key at all, so a carry funding in one hits the unknown-label warn path indebtLegForLabeland charts nothing. They are a coverage gap, not a doctrine. - The numeraire (
ETH,WETH) is the unit of account on an ETH-book strategy, so there is no deviation-from-itself to measure -> the"numeraire"doctrine. This is a different absence from a smart-debt leg's, which is why the two are distinct values rather than a sharednull: the smart-debt case owes the reader a sentence, the numeraire case has nothing to say.
USDC/USDTused to benullhere, on the doctrine that native dollars "are the unit of account, fair by construction". What actually settles it is empirical, not structural: the Dune mirror quote for a native dollar is not identically $1. USDC, USDT and DAI each carry 24 distinct values a day onprices.hour, none cent-aligned (measured 2026-08-05,external-dependencies.md— the same measurement that routed sUSDe to the DEX-ratio query and left these alone). A nonzerotoken_basisrow is therefore written for each every 6h, and the old doctrine was discarding a series we already measure. It also made the modal contradict itself: the same USDT series renders as a card when USDT is the counter-leg of ansUSDe/USDTcollateral pool.That precision is load-bearing at ~$1, since a cent-quantised quote would be ~100bps against a 10bps threshold and would draw quantisation as a peg story. Nothing watches for that regression, and it is worth being plain about which question is and is not being asked. The hole fill notices an hour with NO bar, and the vendor check notices an hour whose bar contradicts a second vendor beyond the band. A quote that keeps printing, on time, at a coarser resolution than it used to contradicts neither: it is the same price rounded, so it fills no hole and disagrees with nobody. What it WOULD do is draw, as a sawtooth on the basis line of every wrapper over that dollar, which is where the 2026-08 measurement above came from in the first place.
Which strategies resolve a basis leg
resolveLegMeta reads the collateral kind + debt symbol from the curated STRATEGY_ORACLE map, falling back to the carry_registry row for a Fluid (fluid-vault-<id>, keyed by vault_id), Aave, Spark or Morpho key (keyed by strategy_key). Morpho takes the morpho-chainlink methodology default (market-linked): a Blue market's oracle is immutable and per-market, so there is no shared-base override to mirror the way Aave's CAPO pairs have. A key on no listed venue has no registry row to resolve and gets no basis blocks.
Resolving the leg is necessary but not sufficient: the panel still needs a token_basis series for the constituent. One class is expected to have none, and it is answered with a different card rather than with a wrong chart:
- Pendle PT collateral has no basis series (
pendle-pthas nocollateralLegMap()entry). A PT is a fixed-maturity claim that accretes to par, so a discount to "redemption value" is its yield, not a dislocation; charting it against the same scale as a wrapper's peg would read as risk where there is none. The collateral side is not blank, though: it carries the PT exit-cost card described under Pricing categories and liquidity below, which answers the question a PT leg does owe a reader.
sUSDS is tracked (added alongside the Morpho carries): its share_rate is USDS per share and USDS is marked at par, so redemption is share_rate x $1 exactly as for sUSDe, and a USDS depeg lands in the sUSDS basis. Backfilled 2026-07-17 from 2024-09-01: 1,394 rows covering 431 distinct days across a 646-day span (2024-10-10 → today). The span is not uniform, and row count alone will mislead you: the grid is daily before ~2025-05 (106 rows / 106 days) and 6h after (1,288 rows / 325 days), so dividing rows by 4 understates coverage by a third. The dominant gap is a 202-day hole (2025-02-05 → 2025-08-25) where DefiLlama serves no sUSDS price; three minor gaps (2d, 13d, 3d) account for the rest, only the 3-day one falling inside the 1y window. share_rate is continuous across all of them, so these are price-side outages, not wrapper ones.
The hole sits inside the nominal 1y window, so the panel's 1-year stats read ~326 days (the sample starts 2025-08-25), not 365. Rows before the hole are stored but fall outside the 1y read, so they move no published figure today. Its worst 1y discount is -0.47% (2025-10-11) and, within that sample, it has never traded beyond the ±0.5% adverse band. The -3.9% print in its series is 2024-10, sUSDS's launch month on thin liquidity, well outside the 1y window.
Known gap: the shortfall is not disclosed in the UI.
readTokenBlockcomputessampleStartandBasisBlockcarries it, but nothing insrc/reads it —MarketDepthPanelpresents its "Largest 1-yr drawdown" stat as a full-year fact with no indication that ~39 days of that year were never observed for this leg. Any token with an upstream price hole understates its own coverage the same way. SurfacingsampleStarton the panel would close it.
See data-pipeline.
For the legs that are charted, the module always measures the discount tail (readTokenBlock(tok, "discount")); the panel frames the sign per side. For an LP collateral leg with a numeraire (ETH) side, a synthetic basis-0 candidate floors the binding constituent, so a favorable tracked leg correctly reads 0 risk. The reader tolerates token_basis being absent (returns null) so the app deploys safely before the migration/backfill runs.
Pricing categories and liquidity
Every tracked asset is valued in one of two ways, and which one it is is a question about the route a holder actually has rather than about our data.
| Category | What it means | How it is valued |
|---|---|---|
| Market-priced | It changes hands on a real venue, so a price exists that is not the issuer's redemption rate. Where a redemption value also exists, the gap between the two is the basis, and it is a measurable fact. | Its own price series: its bars, a routed feed, a vendor's aggregate, or a derivation off a wrapper's bars. |
| Redemption-priced | Either nobody makes a market in it at size, or its primary route holds its price at the rate. | Its redemption rate times the asset it redeems into, chained down until a market-priced asset is reached. |
Every listed fund is redemption-priced by definition and is never tested: a fund share does not trade, its value is its NAV per share, and a venue printing a price against it is quoting a claim on a portfolio rather than making a market in an asset. Every idle claim and every no-base token is market-priced by rule, for the mirror-image reason: a dollar or a bitcoin claim has no redemption rate of its own to be priced at. What is left — the yield-bearing wallet tokens — is what the test below decides.
The test. A token is redemption-priced when either
- at least $5M can be minted and redeemed atomically and permissionlessly by a contract in one transaction, both ways, to an asset of its own book; or
- no real market exists: fewer than 20 trading days of the last 30, or a median daily volume under $100k, or no single pool holding at least $1M against an asset of its book or a recognised counter of that book.
Otherwise it is market-priced. The two limbs are an OR, and they answer different questions: the first says the traded price cannot drift from the rate, because anyone who wanted the difference could take it; the second says there is no traded price worth reading in the first place. An asset can satisfy both, and the answer is the same either way.
A capped-but-instant route counts only up to its capacity. Rocket Pool's deposit pool and a vault's liquid buffer are both real routes and both bounded, so they pin the price only while the bound is at or above $5M. That is why the capacity is a SERIES rather than a fact recorded once.
The three bars are not measured the same way, and the difference is the point.
| Bar | Measured on | Why that source |
|---|---|---|
| 20 trading days of 30 | on-chain trades | a reported figure cannot say which days had trades in them, and a day count is exactly what an incentive week flatters |
| a $100k median day | the price vendor's reported daily volume, exchanges and DEXes together | the bar asks whether a traded price exists, and a token turning over millions a day on centralised venues has one whatever its pools are doing |
| a $1M pool | pool reserves | a balance sheet, not a quote |
Exchange volume counts, and an aggregator quote never does. The exclusion this test rests on is about ROUTES, not about venues: an aggregator routes through a token's native mint and redeem wherever that route is atomic — exactly the primary route this limb has to exclude — so it would report a deep, tight market for an asset with no secondary market at all, and every route-pinned wrapper would read as traded. Volume is the opposite kind of evidence: it says trades happened, and where they happened does not change that. The on-chain median is still measured and still stored beside the reported one, because the two together say how much of an asset's market is on chain; it is also what the bar falls back to for a token the price vendor does not list (sGHO today), which can only tighten the bar, so the verdict states which of the two it read.
The median is taken over calendar days, with a day that did not trade counting as zero where the source can see it: a median over only the days that traded flatters precisely the asset the test is looking for. The reported series covers the days the vendor has published the token, so a token listed partway through the window is a median over fewer days rather than a median padded with zeros — which would measure the listing date instead of the market. What stops a young listing reading as a real market is the trading-day bar, which is on-chain and over the whole calendar.
What counts as the other side of a market is a SHORT, EXPLICIT LIST, and it is not the same question as what creddit covers. A pool is only deep in dollars if what it holds against the token is dollars, and a trade only moved dollars if one leg of it was one, so both readings need the counter to be a unit whose value can be taken as read: a dollar stable at par, or ether and the liquid staking wrappers of it. Anything creddit tracks in the same book qualifies, and so does a recognised counter it does not track — currently frxUSD, crvUSD, USDC, USDT, DAI, USDS, USDe, GHO, PYUSD, RLUSD and USDD on the dollar side, and ether, WETH, wstETH, weETH, rETH and cbETH on the ether side. Adding one is a deliberate edit, because a wrong member overstates a market.
A pool's own share token is never one of its sides. Balancer's stable pools list their own share token as a pool member, and a data vendor reports the pool's balance of it as liquidity — ETHx reads a $25.8M "pool" that way, whose other side is the pool itself. That figure is the pool's own unminted supply rather than depth against anything a holder could sell into, so the side is not counted and the pool is not read: it is neither depth nor a pool the counter rule excluded, and the reading records what it dropped.
Why the two questions had to come apart. Coverage answers "can a wallet here be holding this", which is a decision about what to track; the bar asks "is this a sound yardstick for a market", which is a fact about the asset. Reading the first as an answer to the second made USD3 read as an asset nobody trades: its only venue is a Curve pool against frxUSD, a $104M dollar stable no tracked wallet holds, so $2.1M of real depth was excluded and 1,516 trades spread over all 30 days of the window counted as zero, because the trade data prices neither side of that pair and an unpriced trade was thrown away. Both readings are now taken against the recognised list: a trade the vendor could not price is valued on its dollar counter leg — the amount of the stable that actually changed hands, which is a reading of what moved rather than a price invented for the token — and a trade against a counter on neither path is still dropped from the volume AND the day count, because it is not evidence of a dollar market. Re-measured on 2026-09-22 over the 30 days to that date, USD3 reads 30 trading days of 30, a median $318,427 a day and a $2,099,687 qualifying pool: market-priced on the measurement as well as by declaration.
What is measured, and how often
| Reading | Cadence | Source |
|---|---|---|
| Instant mint capacity, instant redeem capacity, in USD | every 6 hours, at one pinned block | the vault's own getters |
| Trading days over 30 days | weekly | DEX trade data |
| Median daily volume over those 30 days | weekly | the price vendor's reported daily volume, exchanges and DEXes together (DEX trade data for a token it does not list) |
| Median daily DEX volume, informational | weekly | DEX trade data |
| Deepest pool against a book asset or a recognised counter | weekly | pool reserves |
A pool reading that could not reach the end of the venue list withholds the bar rather than failing it. The depth question is about the DEEPEST pool, the vendor hands back twenty at a time ordered by volume rather than by size, and the walk is bounded — so a token with more pools than the walk may read has a FLOOR under its depth, not a maximum. A pool that clears the bar settles it whatever else went unread; a list that ran out inside the walk settles it the other way. Neither being true means the market limb reads "not measured" for that row until the next weekly run, which is the honest answer: storing "no real market" off a reading that never saw the deepest pool would pin the price of an asset a holder can plainly trade.
Every reading is appended with the block or the window it was taken over, and a read that failed stores nothing at all. "The vault can pay nothing" and "we could not ask" are opposite statements, and storing a zero for the second would move a category on a timed-out request. A route with no bound stores that fact rather than a number: a savings module mints against the protocol rather than out of a held buffer, so any figure printed beside it would understate the route by the whole size of it.
A verdict is proposed, never applied. Every six hours each token's category CANDIDATE is recomputed from the readings and compared with the category its registry row declares. A candidate that has disagreed for fourteen consecutive days is reported, and the report reaches a person rather than only a log file; applying it is a registry edit in a pull request. Fourteen days because both limbs move on their own — a buffer empties and refills inside a week, a venue's volume swings with one incentive programme — and a shorter window would propose a flip every time a vault was drawn down. It is reported ONCE, when the fortnight completes, so a disagreement somebody decides to leave in place does not turn into a standing alert.
A day only counts while the readings behind it are fresh, and how long that is follows the cadence of whatever wrote them: the capacity readings are six-hourly, the three market facts weekly, so a market reading stands for the week it covers and a capacity reading for two days. A disagreement with less than fourteen days of readings behind it is reported as insufficient data, quietly, and one whose window has a hole in it is reported separately — the second says a measurement has stopped arriving, which is a different problem from a series that is simply young.
The test is never applied to an asset whose category is fixed by rule. A listed fund, an idle claim, a no-base token and an asset priced THROUGH another one are settled before any reading is taken, so none of them is measured and none can produce a proposal. The last two are more than an economy:
- the market limb asks for a pool against an asset of the token's own book or against a recognised counter of that book, and both limbs are measured relative to a book a no-base token does not have, so it can never satisfy either, and measuring one would record "no market" for an asset that may trade perfectly well;
- a rebasing token whose price comes from its wrapper (stETH through wstETH, eETH through weETH) has no valuation of its own for the test to move. eETH is the case that proves it: its own pools are thin while the wrapper it is priced through trades in size, so the test would read it as marketless and ask, every fortnight, for a change to how it is valued that has no meaning — it is not valued off a market of its own in the first place.
Loopability rides on the same two facts. Whether a levered position on an asset can be opened AND unwound in one transaction is true when the token clears the market bar, or when $5M mints and redeems atomically both ways; neither, and it is false. It is the collateral half of the answer — the venue half is known per carry — and it has no surface of its own yet.
It is a plain yes or no for every asset the test measures. The only assets without an answer are the ones the test never asks about — a listed fund, an idle claim, a no-base token, an asset priced through another one — and an asset whose readings have not arrived yet, which is the difference between "we have not looked" and "no".
Where the counter rule left a deep pool out, the flip proposal says so. The pool bar counts only pools held against an asset of the token's own book or a recognised counter, so a deep pool against a governance token, an exotic wrapper or another yield-bearing share is excluded however large it is. When the pool bar is the ONLY one of the three the asset failed, the excluded pool is at least ten times the counted one and would itself have cleared the bar, the proposal names it — because "no real market" resting on a pool nobody counted and "no real market" resting on a pool that is not there are different statements, and the person deciding the category has to tell them apart. An asset that also fails on trading days or on volume is failing about the market, so naming the excluded pool there would send a reader to the one remedy (recognise the counter) that would change nothing.
Liquidity is a separate column, and it records where entry and exit actually happen.
| Class | What it means |
|---|---|
secondary | The asset changes hands on a real venue. Thin still counts. |
primary_buffer | Entry is a deposit and exit a withdrawal, both clearing against the vault's own buffer at the share rate. |
A NULL class is a declaration, not an omission, and it is the row's own statement that it sits OUTSIDE coverage: no class, no evidence date, no feed, and honest-null market marks (M9) rather than a number nobody stood behind. That is compatible with being SWEPT — a holder should still see a balance and its redemption value — so the rule is "nothing is MARKED without a class", not "nothing is tracked without one".
Every idle asset that trades has a feed. Until September 2026 a par asset in no mirror registry had no series of its own and was marked at exactly $1 whatever the market said (the retired pinned class, M19). They are all on the hourly tape now, which is what makes a depeg of any of them drawable. The one idle row without a feed is the ETH sentinel, which is the ETH book's own unit and has nothing to draw against itself.
What the Market Depth panel shows. One question decides it, and it is the same question the valuation asks.
| The leg is | The card shows |
|---|---|
| Market-priced | Its peg-deviation chart, with the largest one-year drawdown as the headline. |
| Redemption-priced, no market | "Nobody makes a market in this asset at size…", plus what the buffer can pay out right now as an explicit FLOOR when an adapter can read one. |
| Redemption-priced, route-pinned | "This asset can be created and redeemed in a single transaction at its published rate…", because telling a reader that sGHO has no secondary market would be false. What binds on exit is how much the route can take at once. |
A market-priced card also says when its line has STOPPED. The chart is drawn from a price series, and a series can stop being written while everything around it keeps working: the newest accepted bar keeps being served, the line ends three weeks ago, and every statistic on the card is a statement about the past. When the newest reading is more than three days old the card adds one sentence saying so and keeps the chart, because what was read is still what was read. It is a statement about the clock rather than a verdict on the feed, which is why it needs nothing measured beyond that reading's own timestamp.
"At size" is the claim the first sentence makes, and the qualifier is the whole of it. Some redemption-priced rows do trade: sUSDf's registry row says so, and it gets that note because its route is not uncapped in both directions rather than because no venue lists it. What is true of every row that gets the note, and what the weekly measurement behind it tests, is that no single venue holds enough of the asset to exit a position through.
The pinned sentence is reserved for a route that is uncapped in both directions. A route that is atomic but bounded holds the price only while the bound has room, which is a measurement and not something a declaration can promise, so a bounded route gets the no-market sentence instead: srUSDe is atomic both ways and its redeem buffer was empty when its terms were verified, and telling a reader its price cannot drift would have been contradicted by the number printed beside it.
Which leg gets which card follows the category, not whether the asset trades. Two redemption-priced assets change hands on a venue (sGHO, sUSDf) — that is exactly why the pinned sentence exists — so asking "does it trade" would have dropped them from the panel altogether rather than giving them the note they are owed.
The instantly-redeemable floor is omitted where a floor is not a bound. It is the vault's own idle holding, which is what a withdrawal is served from before anything is unwound, so it is a true lower bound for a vault that holds a buffer. A savings module holds none — a redemption there draws on the protocol — so its idle holding is an accounting remainder, and printing it would read as a ceiling orders of magnitude below the truth.
A market-priced leg with too little stored history to draw gets the chart's slot back with one line saying so. That is a statement about the CHART rather than about the asset, which is why it is not a third answer to the question above.
The measurements stay off the card. The verdict is a product statement; the trading days, the capacity in dollars, the pool that was counted and the one the counter rule excluded are how the answer was reached, not something a reader has to weigh. They live in the measurement series and in the six-hourly log, and a render test asserts their absence from the card so a future edit cannot drift them back onto it.
PT collateral keeps its own doctrine: a PT's deviation from fair value IS its implied yield (M34), so there is no peg to chart. What the panel shows instead is an exit-cost card, because the question a PT leg leaves open is not "how far has this strayed from fair" but what unwinding it before maturity can cost.
Two things decide that, and the card carries both.
1. The rate. A PT's price is the discount at the market's implied rate, so a rate RISE marks the position down. The sensitivity is the time left: with T years to maturity, +1pp of rate is about -T% of price, decaying to zero at maturity because the payoff is fixed. So the chart plots the market's implied-rate history, not its price: a price series would mostly draw the mechanical glide toward face value, while the rate is the part that moves against you. The headline stat is the worst 30-day rate rise in the stored history, priced at TODAY's remaining time (the reader is sizing now, and the same rate move costs less the closer maturity gets). Both figures are stated in the card's own words: a rise in percentage points, and what it would be worth as a percentage of position value.
The window is measured against the last sample at or before t - 30d, with a 7-day bridge on how much older that comparison point may be — the same lookback the carry-history readers use to join Pendle's two cadences (6h rpc rows and daily pendle_api backfill). Past the bridge the pair is discarded rather than published: comparing across a 60-day coverage hole would be a 90-day move stated as a 30-day one. A history too short (or too gappy) to contain one window reports nothing, never zero, and a rate that only ever fell is stated as "the rate never rose" rather than as a rise of 0.0pp; those are different facts and the card keeps them apart.
2. Depth. The card states the market's own pool size, and stops there. What a given size actually costs to sell is a live venue quote, which the trade calculator already owns; a second execution model behind a diligence panel would give the same reader two answers to one question.
It closes on what the position settles into: a PT pays a fixed dollar value in its underlying at maturity, so what it inherits from that underlying is solvency risk, not its day-to-day price.
Which token that sentence names is a deliberate choice, and it is not the one the position is marked in. A Pendle market has two claims on the word "underlying": the accounting asset its rate is quoted in (so, the unit of the mark — USDC for the live PT-reUSD market, USDe for PT-srUSDe) and the yield token the PT redeems into, which is the issuer whose solvency the holder is actually taking (reUSD; srUSDe, senior tranche and all). They are different tokens on most live markets. The sentence is a credit statement, so it names the second, read off the PT's own ticker, which is how Pendle names a PT in the first place. Sourcing it from the registry's accounting-asset column would silently restate a Resolv position as a Circle-backed one, and nothing downstream would flag it because the number beside it stays right. Where the ticker names no token, the card prints no settlement line rather than the wrong one.
Everything comes off pendle_market_state — the same series the row's own Historical performance chart reads its collateral leg from, so the card and the chart cannot disagree about what the fixed rate did. The strategy reaches its market through the pendleMarket already stored on its carry_registry config; there is no second strategy-to-market mapping. A PT strategy whose market has no stored history degrades to the explanation alone (no chart, no stat, no invented number). The fixture seeds a term carry with a full history, so the populated card is the browser-verified state; the three degraded ones are pinned by unit tests instead.
Enforcement is a build-breaking test, not a convention: src/lib/portfolio/mirror-coverage.test.ts fails the build when a tracked asset carries no liquidity class (the type does half of this: the field is required, so an unclassified entry does not compile), when a redemption-priced asset still occupies a slot in the raw price mirror, when an asset claims two price-source paths at once, or when the two redemption-priced reasons disagree with the route the same row declares. There is no allowlist.
What a static test CANNOT check is whether a market exists, which is exactly why the measurement series is not a test: it is evidence, gathered on a schedule, and the verdict it supports is a proposal for a person. See Operational processes.
Reading the chart
Each charted constituent gets a ~1y peg-deviation chart (MarketDepthPanel). The x-axis ticks are dated (AUG '25, OCT '25, ...): the window is about a year, so a bare month is ambiguous at both ends. Dated ticks cost width, so a window longer than 7 months steps every second month. Hovering reads out the exact date and the deviation at that snapshot, and landing on the marked trough names it ("Largest 1-yr drawdown"), which is the same figure the card's header stat shows; the header carries no date, so the hover readout is where the trough's date lives. The worst-1y point is force-included in the downsampled series, so the marker always sits on a real plotted reading rather than floating above an extreme the ~160-point downsample skipped.
Oracle transparency
Per-strategy, the carry-row ORACLE tab explains how the execution platform's oracle works, how it is wired for this vault's tokens, and what that means for liquidation. Built in src/lib/data/oracles.ts, split into:
- Curated copy (this file, changes rarely, reads like a credit memo):
PLATFORM_MECHANISM(per-platform),COLLATERAL_PROFILE(per-collateral:provider/detail/protects/doesNotProtect/ a TradFi analogy),TOKEN_INFO(per-token valuation + issuer risk for generated prose), anddebtNote. Fluid vault copy is sourced fromsrc/lib/data/fluid-oracle-copy.ts. - Live on-chain reads (drift-prone facts, read on demand, 30-min cache): the oracle contract address, its
description()string, the current rate/price, and (Fluid) the vault oracle's operate rate. Aave/Spark resolve viaAaveOracle/ SparkgetSourceOfAsset; Fluid resolves the oracle + operate/liquidate prices out ofVaultResolver.getVaultEntireData. Reading live means a protocol swapping an oracle can't make the curated copy silently lie;scripts/resolve-oracles.tsre-derives both sets and flags drift.
Pricing mode (PricingMode, resolvePricingMode) is a per-pair property, not purely a methodology one — the same adapter family can be wired redemption-style or market-linked depending on which base feed it composes with the debt leg:
redemption— the wrapper's on-chain redemption rate; any market base feed is the same feed instance pricing the debt leg, so market moves cancel inside the health factor and a secondary depeg is an exit cost only (the Aave ETH pairs;aave-susde-usdt, both legs on the capped USDT/USD feed).market-linked— wrapper ratio redemption-marked, but the collateral and debt base feeds are independent, so their relative move enters the health factor and can liquidate (aave-susde-usdc: collateral on capped USDT/USD, debt on USDC/USD, leaving a USDT-vs-USDC stable cross).market— the collateral itself is marked at a secondary-market feed; the depeg feeds the LTV directly.
The resolved mode gates both the basis panel framing and the generated pricing prose so the two cannot disagree. Fluid vault oracle metadata is generated from fluid_vault_registry (fluidMetaFromRegistry, key fluid-vault-<id>); only Aave v3 / SparkLend keep hand-keyed STRATEGY_ORACLE entries (their auto-discovery isn't wired yet). House rule throughout: a failed read renders null (leg without a number), never a fabricated 0.
Pendle PT pricing: verified per market, never per asset class
"Pendle PT collateral" is not one pricing mechanism, and the difference decides whether a rate move can liquidate the position at all. The two live PT markets price the same kind of instrument in opposite ways, so the mechanism is recorded per market as a typed PtPricingDescriptor (src/lib/data/pt-oracle-copy.ts) carrying mechanism, an optional rampAprPct / stalenessHours, and the verifiedOn evidence date. The section-03 narrative is generated from that descriptor; the on-chain evidence for each entry lives in code comments beside it, never in user-facing copy.
| Mechanism | Meaning | Live example |
|---|---|---|
market-min-ramp | mark is min(live traded price, fixed discount line): tracks the market down, capped up | Morpho PT-reUSD-10DEC2026 markets |
linear-ramp | preset straight-line discount over a base feed; the PT's traded price is never read | Aave PT-srUSDe-22OCT2026 |
market | the live traded price, uncapped | none listed |
unverified | not verified on-chain yet; the panel asserts no mechanism | fallback for any new PT market |
Morpho: min-of-two, and stale means FROZEN. The market's immutable oracle composes a single base feed which is an EIP-1167 proxy to an OjoPTFeed. Its latestRoundData() returns the minimum of two sub-feeds (the PT's live Pendle price, and a deterministic ~6%/yr linear discount ramp accreting to $1 at maturity) and hard-reverts with StaleOracleData when either sub-feed is older than STALENESS_THRESHOLD (24h, read on-chain). Two consequences the copy states plainly: the market freezes rather than ever serving an old price (and because Morpho Blue reads the oracle only in borrow, withdrawCollateral and liquidate, a freeze still leaves repay and supplyCollateral working, so a user can de-risk through it); and because the market leg is the lower of the two today, a rich PT print buys no extra borrowing power — the mark stops at the ramp line.
Aave: a pure ramp, verified, not assumed. AaveOracle.getSourceOfAsset on the PT resolves a BGD Labs PendlePriceCapAdapter whose latestAnswer() is assetToUsd * (1 - timeToMaturity * discountRatePerYear / SECONDS_PER_YEAR), with no market read and no min(). It was confirmed behaviourally as well as from source: across five archive blocks the adapter reproduced that closed form bit-for-bit while the PT's traded price moved independently and down over the same interval that the adapter's mark moved up. So market moves cannot move the liquidation point here. What can: discountRatePerYear is a governance parameter (raisable to MAX_DISCOUNT_RATE_PER_YEAR), which marks the PT down in one step, plus the existing stable-cross channel already covered by the resolved pricing mode. There is no staleness threshold anywhere in this path, so the descriptor deliberately carries no stalenessHours and the copy states the absence of a freeze as the risk it is.
Verification discipline. A PT market only gets a mechanism after it is verified on-chain, with the evidence and the date recorded. An unrecognised PT market resolves to unverified and the panel says so rather than inheriting a neighbouring market's story — a guess here would be a claim about what can liquidate a user. Governance-settable numbers are read live per request (pendleCapAdapterRates), never frozen into curated copy, and every quantity the narrative quotes (days to maturity, the per-1pp mark sensitivity, the same figure times the pair's max leverage) is computed at request time from the registry's maturity_ts and ltv, so the panel and the /carries row cannot disagree. Failed reads drop the quantified sentence rather than guessing.
Money market funds
Every metric on /money-market-funds is either a share-rate ratio (the returns) or an on-chain read at one pinned block (everything else). The pure arithmetic lives in src/lib/data/money-market-fund-math.ts, which imports nothing at runtime so the client table can call it, and is unit-tested case by case in the file beside it.
Units, once, for all of it. Every quantity is in HUMAN units of the fund's own deposit asset unless the name says otherwise. Fees, penalties, LLTVs, utilizations and shares are fractions, never percentages. null means "we could not read it", never zero: a listed fund cleared a readability gate before it appeared, so a null in a core column is a live read failure and the cell says "n/a" while the row stays.
Exit liquidity
exit liquidity = idle cash + Σ_markets min(fund position, market unborrowed balance)Published on the screener as the Liquidity column, as a share of the fund. What a depositor could take out right now. A market cannot hand back more than its lenders' unborrowed balance, and a fund cannot take out more than it put in, so the binding side flips market by market. Negative liquidity, which a market briefly reports when an interest accrual pushes borrow past supply, contributes zero rather than eating into another market's contribution.
The figure is PER FUND and is not additive down the column. Several funds lend into the same markets and each one's exit liquidity counts the same unborrowed balance, so summing the column would count that liquidity once per fund. The column's own definition says so; the figure itself carries no tooltip, and the amount a holder could take out in their own units is in the drawer's Exit section rather than on every row of the column.
Cross-checked every tick against Morpho's own published liquidity for the same vault. Verified equal to the unit on four Vaults V2 funds at block 25,797,724 and within 0.06% on MetaMorpho, where the difference is the blocks between the two readings.
A Vaults V2 exit is a different mechanism, and the split matters. exit() returns the vault's own cash and, if it has a liquidity adapter, whatever that ONE route can release. Nothing else. Every other position needs forceDeallocate, and that costs the adapter's penalty. So a V2 fund publishes two numbers: the exit liquidity above, and a force-deallocatable amount quoted with its penalty beside it. maxWithdraw cannot be used to shortcut this: the contract hardcodes it to zero on every V2 vault, and reading that as "no liquidity" is the easiest way to publish a wrong number on this page.
A fund that HOLDS another fund is walked through to the child's own exit path (bounded to two levels). A MetaMorpho child answers maxWithdraw honestly; a V2 child hardcodes zero there, so its own cash and liquidity route have to be read instead. Without that walk a fee wrapper over a fully liquid fund reports zero exit liquidity, which is the opposite of the truth.
Just-in-time depth (MetaMorpho only)
JIT depth into market X = min( X's public-allocator inflow cap,
Σ_other markets min(outflow cap, fund position, market unborrowed),
X's own cap headroom for this fund )How much more a market could absorb right now by pulling capital out of the fund's other markets. Anyone can trigger it through Morpho's public allocator, so it is depth a borrower can actually reach rather than a curator promise.
The triple minimum on the donor side is the whole point. An outflow cap is only a permission, the position is what is actually there, and the market's unborrowed balance is what can actually leave today. Measured caps on live vaults run past a trillion dollars; a formula that trusted the cap alone would print nonsense. Pinned by a named unit-test case.
The third clamp is the target's own cap headroom, and it is not optional.PublicAllocator.reallocateTo ends by calling the vault's own reallocate with type(uint256).max on the supply leg, and MetaMorpho.reallocate reverts with UnauthorizedMarket when config[id].cap == 0 and with SupplyCapExceeded when the position would pass the cap. So a market this fund caps at zero can absorb NOTHING, whatever its neighbours could release, and a market near its cap can absorb only the room left under it. Three rows measured at block 25,798,744 that the unclamped formula published wrong: Yearn OG USDC / tBTC quoted $39,970 into a market capped at zero, Yearn OG USDC / YFI quoted $518,303 against $329,293 of headroom, Steakhouse USDC / weETH quoted $20,104,122 against $19,861,800. All three are unit-test fixtures.
A cap the refresher could not read yields a null depth, not a number and not a zero: an unknown ceiling makes the depth unknowable, while a zero would claim the market is closed.
Vaults V2 has no equivalent: the public allocator calls into MetaMorpho. Those three columns read "n/a" on a V2 fund, never zero, and the tooltip says the mechanism is a MetaMorpho one and that V2 uses force-deallocate instead.
Funded, permitted, closed
Every market on a fund's list is one of three things, and the drawer treats them differently:
funded the fund's position is above MMF_PERMITTED_ONLY_SHARE (1e-6) of its size
permitted dust or nothing, and the market is open to it (cap > 0, enabled)
closed dust or nothing, and the cap reads zero or the market is switched offA CLOSED market is dropped: not written to the allocation table, not counted among the fund's exposures, not rendered. The vault refuses a supply to a zero-capped market outright, so an empty one is a market the fund has LEFT, and carrying it put a row with a zero cap, zero headroom and no reachable depth in the drawer. Live instances on day one: Yearn OG USDC (tBTC), Gauntlet USDC Core. A zero cap with a REAL position is a market being wound down and stays funded, because the money is really there.
An UNREADABLE cap greys a market rather than dropping it: "we could not read the ceiling" is not evidence the curator shut the market.
The three-way split is applied before any figure is computed from the market list, so the exit figure, the shares and the table all describe the same set and the identity idle + Σ withdrawable_here = withdrawable_now continues to hold exactly.
Bad debt
this fund's share = market's bad debt (USD) × min(1, fund position / market supply)
fund total = Σ over its marketsMorpho spreads a realised loss across a market's suppliers in proportion to what each supplied, so a fund holding a tenth of a market wears a tenth of its bad debt. Attributing the market's whole figure to every fund lending into it, which a vault-shaped query invites, would overstate every one of them.
TWO FIGURES, TWO CHIPS, NEVER SUMMED. bad_debt_usd is the fund's share of loans in its markets that have no collateral behind them RIGHT NOW: a loss that has not landed and might still be repaid. realized_bad_debt_usd is its share of what those markets have ALREADY written off, which is a loss already inside the share price every return on this page is measured from. The bad_debt chip fires on the first, the past_bad_debt chip on the second, and the drawer states them as separate sentences because adding them would publish one number that is partly historic and partly hypothetical. Live census of Morpho mainnet on 2026-08-20: 15 markets carried current bad debt and 68 carried realized; one carried $1,369,932 realized against $10.63 of remaining supply, so a fund that had taken a permanent hit there read perfectly clean off the current figure alone.
This is the one input on the page the chain cannot confirm. Whether a loan is backed is a statement about a market's whole position set, which no single contract read returns and which enumerating needs an indexer. Morpho's API is therefore the source, read once per tick BY MARKET ID so it covers both vault generations rather than only the first. A market absent from the response is null (not measured), never 0: only a measured zero is a clean bill. The market list comes from what the funds held at the previous tick, so a market a fund entered in the last six hours shows as unmeasured until the next one, and the drawer says "measured across N of M markets" whenever the reading covered fewer than all of them.
A FAILED READING NEVER ERASES A FIGURE OR SILENCES A CHIP. The reading is one API dependency and it fails soft. Rolled up naively, a failed tick produced a fund-level null on every fund at once: the chips vanished, the drawer's disclosure vanished, and the stored figures were overwritten with NULL, so one degraded response read as a clean bill of health across the whole page. The tick now leaves the previous figures and their chips standing, does not upsert the bad-debt columns at all, and carries bad_debt_as_of so the drawer can label a reading older than a day with its age.
The flags, and why none of them is a gate
The chips in a fund's drawer header are FACTS a reader might weigh differently from the next reader. None of them keeps a fund off the tab; the eligibility rules in Processes do that, and they are a separate list.
| flag | fires when |
|---|---|
no_timelock | the fund's notice period on the actions that can move a depositor's risk is zero |
permissioned_deposits | an allowlist stands between a would-be depositor and the fund |
exit_restricted | an allowlist stands between a holder and their money on the way out |
pending_cap_raise | a cap increase has been submitted and is waiting out its notice period |
cap_raise_executable | that submitted increase has finished its notice period and can be applied at will |
zero_idle | uninvested cash is under 0.01% of the fund |
deposits_closed | the fund is not accepting new money today |
bad_debt | the fund's share of currently-unbacked loans in its markets is at least 0.0001% of the fund |
past_bad_debt | its share of what those markets have ALREADY written off clears the same floor |
dominant_depositor | one holder owns a large share of the fund |
Both bad-debt chips carry a dust floor, for the same reason zero_idle does. Found on the first live run of the realized reading: a fund holding 0.03 of its deposit token in a market that had written a loss off wore half a cent of it, and a strict > 0 test put an amber chip on the fund and a sentence reading "$0 has already been written off" under its allocation table. One millionth of the fund is $40 on a $40M book. The figure is still recorded either way; only the warning is withheld.
zero_idle is a share, not a strict zero. Every other dust question on this page carries a threshold and this one did not, so a single unit of rounding remnant cleared it: Gauntlet USDC Prime holds 0.000001 USDC against $28.2M and went unflagged, along with eleven more funds just as cashless, while 21 funds at a literal zero were flagged. At one hundredth of a percent, a fund with cash a depositor could actually redeem out of keeps it and every other fund reads as what it is. An unreadable balance is not zero cash and never fires the chip.
The binding cap on a Vaults V2 fund
binding cap = min over the market's cap ids of min(absolute cap, relative cap × total assets)A single Morpho market consumes THREE cap ids on a V2 vault: one for the adapter, one for the collateral token, and one for the exact market parameters. An allocation has to clear every one of them, so the ceiling that binds is the smallest.
Looking the cap up by market id returns zero. The ids are hashes of encoded id-data, not market ids, and are read from the adapter's own ids(marketParams). An absolute cap of type(uint128).max means uncapped and renders as such rather than as an astronomical number; an absolute cap of zero means the market is DISABLED, not permitted-but-empty.
"Uncapped" is only ever said about a ceiling that was READ. A cap call that does not come back leaves the same empty value as a market with no ceiling, and printing "Uncapped" there would tell a reader the fund may lend without limit into a market nobody could read. The two are recorded apart, and an unread ceiling reads n/a, with its headroom n/a beside it, and is left out of the count of markets the manager can fill without asking anyone. On a first-generation vault the ceiling is always a number on chain, so an empty one there can only mean a failed read.
A market with a cap and no meaningful allocation renders greyed: it is where the money is allowed to go next, which is part of what a depositor is agreeing to. The test is relative to the fund's size rather than an exact zero, because live vaults leave single-wei remnants in markets they have fully exited.
Notice period (timelock)
One line for how long a change to the fund takes to land.
- MetaMorpho has a single global delay. Zero renders "none" in red; an unreadable value renders "n/a" and does not flag.
- Vaults V2 sets one delay per action, and the summary covers only the SIX that can newly expose a depositor or newly restrict their exit: raising an absolute cap, raising a relative cap, adding a lending route, changing which routes are allowed, and the two gates that decide whether a holder can hand back shares and receive assets. All equal reads that duration; a spread reads
3-7d per action; all zero reads "none".
An ABDICATED action is permanently delayed, not instant. Abdication means the curator has given up the ability to call that function for good, and the stored duration stays at whatever it was, usually zero. Reading those zeros at face value would put a red "no notice period" chip on almost every eligible V2 fund: when this was measured, 41 of the 43 eligible at the time, because almost all of them have permanently surrendered the ability to swap their adapter registry. The summary excludes abdicated actions from the range and reads "permanent" when every core action is abdicated. This is the single most load-bearing rule on the page and has its own named test, plus a mutation guard that flips only the abdication flag and asserts the verdict flips with it.
decreaseTimelock is never counted. It reads zero on every live V2 vault by construction, because its real delay is the delay of the function whose delay is being cut. Including it would make every V2 fund read "none".
The protective actions (removing a route, lengthening a delay, abdicating) and the fee setters appear in the drawer's table and stay out of the one-line summary. A LIVE fee setter reading zero is painted red in that table even so: it is the one governance parameter that costs a depositor money directly, and Sentora RLUSD Main ($315.0M) shows "3d" in the Timelock column while its own table shows setPerformanceFee at zero seconds, live.
What MetaMorpho's one delay actually covers, and what it does not. Read from the deployed source of both live implementations (MetaMorpho.sol and MetaMorphoV1_1.sol):
| Action | Delayed? | Role |
|---|---|---|
submitCap, RAISING a cap | yes | curator |
submitCap, lowering a cap | no, immediate | curator |
submitMarketRemoval | yes | curator |
submitTimelock, SHORTENING the delay | yes | owner |
submitTimelock, lengthening it | no, immediate | owner |
submitGuardian, with a guardian already set | yes | owner |
submitGuardian, with none set | no, immediate | owner |
setFee | no, immediate | owner |
setIsAllocator | no, immediate | owner |
setCurator | no, immediate | owner |
The drawer's first-generation table is built from that list and marks the immediate ones "immediate · owner", with the fee row in red. Stamping the vault's delay uniformly across every action, which an earlier version did, told a depositor in all 29 listed MetaMorpho funds that they would get a week's notice before the fee moved. They would not: the owner can take it to the protocol's MAX_FEE in a single transaction.
Realised APY windows
windowApy from the multi-strategy screener, reused verbatim rather than written twice: the realised share-rate ratio between the two window endpoints, annualised by the actual elapsed time, anchored on the newest snapshot at or before latest − days. Net of fees, because fees are already inside the share value, and excluding reward tokens entirely.
The instantaneous rate the protocol quotes is stored but is a TOOLTIP figure only. It is never a column, never sorted on, and never substituted when the realised figure is null.
A stalled series publishes no window. The windows are anchored on the fund's own newest snapshot by design, so a fund whose share rate has not been read for ten days would otherwise report a "24h" figure measured between day eleven and day ten, under a fresh timestamp on the page (the screener's as-of line tracks the on-chain state read, which stays current even when a fund's rate series has stopped). Past two window lengths, a window is null and the cell reads "n/a". The repo already carries a live instance of the failure mode: five curator vaults fail every token-yields run.
Exposure
What a fund is lending against today: every collateral it holds at least MMF_COLLATERAL_DUST_SHARE (0.5%) of the fund in, canonicalised, ordered by weight, heaviest first. A collateral's weight is the SUM of its markets' shares, because one collateral backs several markets at different LLTVs and what the fund is exposed to is the whole of what it lent against that token. The dust floor is measured on that sum, not market by market: a token the fund lends against in four markets at 0.3% each is 1.2% of the fund, which is something a depositor's money really rides on, and thresholding each market separately would drop it from the coin marks while the concentration beside them still counted it.
The screener's Exposure column draws the first three as coin marks and counts the rest as +N; that N counts COLLATERALS, the same thing the marks are, so three marks and a +2 means five collaterals. It is not the market tally, which is a different and usually larger number. Dust in a market a fund is winding out of is below the threshold and so is neither drawn nor counted: the column states what a depositor's money currently rides on, not everything the fund has ever touched.
The column ranks on how many collaterals a fund holds. Three states, not two, and only one of them is "none": a fund whose book has not been read reads "n/a", one that is in no market at all reads "none", and one that IS in markets but draws no coin (every position below the floor, or collateral tokens that did not resolve this tick) reads "n/a" as well, because "none" there would deny an exposure the fund really has.
Hovering the coins opens the breakdown: the first MMF_EXPOSURE_BREAKDOWN (5) collaterals of the same heaviest-first list, so its first rows are the coins in the cell in the same order, each with its share of the fund, and a +N more for the rest.
collateral share = (sum of every market the fund lends against that token in)
/ total assetsThe denominator is the WHOLE fund, idle cash included, for the reason the concentration below gives: the reader is asking how much of their deposit rides on each token, and money that is not lent rides on none. So on a fund holding cash the shares add up to less than 100%. Every listed share is at least the half-percent dust floor, which is measured on the whole fund too (the refresher's per-market share divides by total assets, idle cash included); a real share never prints as 0.0%. A collateral with any market whose allocation could not be read has no share (n/a), because its readable markets alone would understate it; the token is still named. An unreadable fund size withholds every share. Pointer only: on a touch screen a tap opens the row, whose drawer lists every allocation, and a screen reader hears the same shares in the cluster's label.
Concentration and the double count
top 3 share = (sum of the three largest COLLATERAL exposures) / total assets
where a collateral's exposure = the sum of every market the fund lends
against that token inPublished to the assistant (topThreeCollateralShare), no longer on the row: the screener's hover breakdown gives each collateral's own share instead.
The figure covers exactly the collaterals the column drew, so a reader who sees three coins and asks the assistant gets the share of those three. Two consequences follow. A collateral under the dust floor is outside the figure as well as undrawn, even where fewer than three clear the floor. And money the tick could not attribute WITHHOLDS the figure rather than sitting quietly inside it: an allocation that could not be read is unknown at any size, and a market whose collateral token did not resolve is a real position that can never become a coin, so where it is large enough to belong in the top three, no honest share can be stated for three coins that are not the fund's three largest.
The two halves are deliberately asymmetric, and each half answers a different half of the reader's question.
Idle cash is not a candidate. It is not lent against anything, so it is not an exposure, and treating it as one would let a fund that has parked its money read as concentrated in cash.
Idle cash IS in the denominator. The question is what share of a deposit rides on three collaterals, and money that is not lent out rides on none of them. Normalising over deployed capital instead prints top 3 100% on a fund that is 99.998% uninvested, which is the least concentrated book on the tab, and contradicts that same fund's allocation table one line below in its own drawer. Measured live at block 25,799,307, when the candidates were still markets: Vault Bridge WBTC 0.0% (was 100%), sky.money USDS Flagship 20.0% (was 100%), Metronome msUSD Vault 37.5% (was 100%); ten of the 43 listed funds carried at least 2% idle and were overstated by that margin. Cash-light funds were unchanged (Steakhouse USDC stayed 90.6%). Those readings predate the move to collateral candidates and are not current values: grouping raises a figure on a fund lending against one token in several markets, and restricting it to the drawn set lowers it on a fund with fewer than three collaterals above the dust floor.
A holding in ANOTHER listed fund is likewise not a candidate: it is exposure to that fund's whole book rather than to one collateral, and the Exposure column draws no coin for it either.
Null when the fund's size could not be read, when any single market's allocation could not be, or when money the tick could not attribute to a named collateral would belong in the top three: a share of an unknown total, or of a set the reader cannot see, is not a measurement. Zero is a real answer, and means the fund lends nothing out or nothing above the dust floor.
of which via other listed funds is the part of a fund's size that is itself deposited in another fund on the same page. Two funds in that relationship both report the money, so the totals across the list double-count it. The page shows the overlap and does not net it: a depositor in the outer fund really does own that exposure, and netting would understate the fund they actually bought. It is computed in the reader rather than the refresher, because the listed set changes on every approval.
What is not redeemable today, and why no wait is quoted
shortfall = TVL − exit liquidity (clamped at zero)
shortfall share = shortfall / TVLThat is the whole of it. The page publishes no estimated time to a full exit, deliberately. A Morpho market refills from exactly two places, a borrower repaying and a new lender depositing, and neither is forecastable from anything this page holds. Interest accrual is not a third: Morpho._accrueInterest adds the same amount to totalBorrowAssets and totalSupplyAssets, so liquidity = supply − borrow is invariant under it and no token moves. A model whose only refill was accrual therefore produced a number from a mechanism that returns no cash at all.
An earlier version of this page did publish one, and it was degenerate in both directions. Across the 51 funds listed when that was measured: 22 read "not estimable", 26 read "over 1 year", 3 read "now", and not one intermediate value appeared anywhere. Worse, it contradicted the headline three lines above it, quoting "over 1 year" under "100.0% redeemable right now" on a fund whose entire shortfall was ten dollars.
Below MMF_SHORTFALL_DUST_SHARE (0.1% of the fund) the shortfall is rounding rather than a queue, and the panel says nothing further about it. Above it, one factual line names what the remainder waits on: borrower repayments and new deposits, neither of which can be scheduled. On a Vaults V2 fund it also names the force-deallocate route and its penalty, which IS a way out that does not wait on either.
Depositor concentration
The largest holder's share and the top ten's share, of the fund's shares. Morpho's API is the only practical way to ENUMERATE holders, so it names the addresses; every balance is then read on chain against the live share supply, which is what makes the published figure a fact rather than a report.
MetaMorpho positions can be ordered by size, so the top ten are exact. Vaults V2 positions cannot, so up to five pages are fetched and sorted in process; past that the figure is a floor and the drawer says "at least".
Size, and what it is quoted in
Every view quotes a VALUE, in the unit the switch names.
size shown = tvl_usd when the switch is USD
= tvl_usd / denomination_price_usd when it is ETH or BTC
denomination_price_usd = the WETH bar (ETH) or the WBTC bar (BTC), same tickThe sort accessor and the Min TVL screen read the same figure the column prints, so all three agree by construction.
A deposit token is not always worth one unit of the denomination its ticker names, which is why every view converts. A fund holding 21,538,523 of a synthetic dollar trading at $0.7330 is a $15.79M fund, and printing the token count with a dollar sign in front of it overstated a live, listed fund by 36% in the column, in the sort that ranked it and in the screen that filtered it. The ether view had the same failure in the other direction: ranked on token counts it put a 2,026 token fund above a 1,863 WETH fund worth 25% more, and a "2k ETH" screen then kept the smaller one.
The price behind that conversion is quoted under the strict bounds (a 0.9 confidence floor and a six-hour staleness bound), the same gate every other published mark in the product uses. A quote that fails them is refused and read as NO price, which never removes a fund: the last good price pair stands for up to a day, the row carries a price carried forward flag, and the size tooltip names the day it was taken. Past a day the size reads unavailable rather than being valued at a price from a different market. The pair travels together, since both the converted size and the distance from par are ratios of the two prices.
Par is the deposit token's own redemption value, not one unit of its ticker: redemption_rate is 1 for a token that is a dollar or an ether by design and the token's own tracked on-chain rate for one that accrues (wstETH, sUSDe, sUSDS), read from token_yield_apy.share_rate and no older than a week.
par deviation = asset_price_usd / (denomination_price_usd × redemption_rate) − 1This is the same quantity token_basis publishes for a tracked wrapper, computed under a looser pairing rule: both prices come from one call, but the redemption rate may be up to a week old, where the basis series pairs the rate with its numeraire quote at the newest bar the two share. The looser pairing cannot move a 3% gate (a week of drift on even a 25% wrapper is under half a percent, and it biases mildly upward), and it buys the property the gate needs, which is that the size column and the listing decision are quoted from one fetch. Where both surfaces ever describe the same token, read the basis series as the tighter of the two.
A token whose redemption path is not tracked has no par to be far from: the deviation is null, the row carries a par unmeasured flag, and neither the chip, the tooltip nor the listing rule asserts a distance from a figure nobody established. The same is true of a wrapper whose path IS tracked but whose rate could not be read this run, one over a week old included.
The listing rule keeps one backstop, and only on tokens with no tracked path at all, against their MARKET price rather than against a redemption value: such a deposit token outside the same band against one nominal unit of its column is held out, because a fund taking deposits at $0.73 cannot sit in the dollar column whatever it turns out to redeem for. A tracked wrapper is never judged that way: we already know it does not sit at one nominal unit, so the comparison would only punish a gap in a rate feed. See Processes F.1 for what the listing gate does with each of the four bases.
No figure on this page carries a currency symbol unless it was priced. Every amount inside a drawer is a balance of the fund's own deposit token and is quoted in that token. A fund whose size is unreadable in the view's own unit is WITHHELD by a Min TVL screen rather than passed through: not knowing how big a fund is, is not evidence that it clears the floor.
The Min TVL screen is entered in the switch's own unit and clears when the switch changes, so a "10" meaning ten million dollars cannot silently become ten thousand ether.
Benchmark
Dollar funds are compared against SOFR, which is the cash rate a depositor is giving up. Ether and bitcoin funds are compared against the plain Aave v3 supply index for WETH and WBTC. The stats tower's "vs benchmark" line is the fund's trailing 30-day realised figure minus the benchmark's over the same window, both through the same annualisation.
SOFR is a business-day series and this page is a calendar-day one, so the last published fixing is carried forward to every calendar day. That is the standard convention for a business-day index and it is the economically right one: the index does not move over a weekend because no interest is fixed then, not because the rate is unknown. The value on a Saturday IS Friday's value.
Left unfilled, the consequences were all silent. The spread band between the two curves needs a value on both sides of a day, so it broke into roughly weekly slabs on every dollar fund (measured: 53 of 181 days in a six-month window had no benchmark reading); ~29% of benchmark APY points dropped; and the two cumulative curves could start up to a long weekend apart. The fixture database generates SOFR at interval '1 day', weekends included, so no test or screenshot could see any of it.
There is exactly ONE fill rule and it lives in the reader (fillCalendarDays in money-market-fund-math.ts). It fills only BETWEEN published fixings, never past the newest one and never back before the oldest, and it refuses a gap wider than six days: a stretch that long is a paused refresher or a missing backfill, not a holiday weekend, and it is left as the hole it is. The chart aligns what it is handed to the fund's own days (alignBenchmarkByDay) and carries nothing: whichever of the two rules is looser wins, so a second unbounded carry in the chart would silently repeal the reader's bound. Concretely, with the chart filling on its own, a fortnight-long SOFR outage drew an unbroken flat benchmark, put the 30-day benchmark line at 4.30 x 16/30 = 2.29% (a 201bp overstatement of every dollar fund's excess) and widened the cumulative-gap figure by 16.5bp against a benchmark that had simply stopped being measured.
The "vs benchmark" 30-day figure anchors on PUBLISHED fixings, not on the filled series. The window anchors at latest − 30 days and annualises by the calendar time it spans. The newest fixing is always a business day, so that anchor lands on a Saturday when the newest is a Monday and on a Sunday when it is a Tuesday; on the filled series it would then read the previous Friday's index and measure 31 or 32 days of accrual against a 30-day denominator, worth up to 29bp of invented benchmark return at today's SOFR, alternating in sign with the weekday, on two business days in five.
The chart's benchmark is trimmed to the fund's first DAY, not its first timestamp. A fund's 6h snapshots land at whatever hour, a SOFR fixing is stamped at midnight, and comparing full timestamps dropped the benchmark reading for the fund's own first day on every chart, rebasing the two cumulative curves a day apart and overstating the gap in the fund's favour by one day of benchmark return.
Portfolio (read-only) performance
The signed-in portfolio (/portfolio) is a fixed-income book monitor: an honest, yield-only view of a wallet's positions on creddit-covered venues plus the bare wallet balances of the tracked tokens (the wallet venue, taxonomy T2 — yield-bearing profile assets, the top-TVL idle stablecoins, ETH, and since the 2026-09-16 coverage rule every tracked token with NO base as well: the bitcoin wrappers, gold, the governance tokens. Base decides which VIEW a holding appears in and never whether it is read, so a no-base balance is swept and stored like any other and listed in the All view alone), not a full net-worth view of every token a wallet holds. The methodology is locked as M1 through M9 in docs/plans/portfolio-read-only-plan.md; every number is derived at read time by the pure engine src/lib/portfolio/v2/ from the venue readers' (qty, index) snapshots and the flow ledger. The engine has no DB or RPC imports (its unit tests are the proof of the methodology, so keep them in lock-step). The same one-annualisation convention as the rest of the app holds throughout: yield is the realised ratio of a compounding index, never a diffed balance and never an average of per-snapshot rates.
M1 — the book partition and read-time inclusion
Performance is split into two books by accounting asset, each denominated in its own unit: USD, ETH. Price moves of ETH against USD never appear inside a book (that is the entire point of the partition). A third book, BTC, was retired in August 2026: its assets are declared exclusions now, valued at market in USD and never charted (see Portfolio → Retired: the BTC book). A book is a denomination, which is not the same question as which view a position is shown under: a cross-currency position spans denominations and is presented in the All view at value, with no return stated across them (M22). Each leg maps to its book via its accounting asset in src/lib/portfolio/buckets.ts (bookForAccountingAsset); an asset the map does not recognise is EXCLUDED (valued, never blended, and WS8-alerted). Since T2 the runtime source of truth is the onchain_credit.portfolio_tokens registry (loaded as bookMap by registry.ts loadPortfolioTokens): when it carries an address its book wins, and a NULL book resolves to EXCLUDED (valued in USD, never charted, §3.8). A NULL book is a declared exclusion, used where no book unit honestly applies: a non-based fiat (EURC), a different denomination altogether (XAUt is gold, AAVE is a governance token), or a dollar-denominated token with no par claim — apxUSD redeems only for whitelisted entities, at a value tracking an offchain preferred-share basket rather than a dollar, and has traded at a persistent discount, so charting it in USD would mean either asserting $1 it does not owe or skipping the leg outright. An accruing wrapper over such a token inherits the same answer (apyUSD, the ERC-4626 over apxUSD; sUSDat, over the same offchain basket). USDai, added by the 2026-09-16 coverage rule, is the same kind reached by a different mechanism: its Ethereum contract is a bridge-minted mirror whose whole supply surface is mint/burn behind a bridge role, with no redeem, withdraw or convertToAssets in its ABI, so whatever USD.AI redeems is on its hub chain and there is nothing here to be par to. The distinction the column is making is redemption, not price: a NULL book still values the holding at its own market price in dollars, and it is the $1 it does not owe that a book would have asserted. The whitelist test is the one that decides these, and it is applied to the general holder rather than the depositor: migration 074 excludes the Pareto FalconX tranche AA_FalconXUSDC and its 1:1 wrapper wFalconX on exactly that ground, since the tranche transfers freely while redemption at face value reverts for any caller without a credential, so the venue holding it as collateral, a liquidator seizing it, and anyone who received it by transfer all hold something with no par claim behind it. The book map is a memoised LIVE READ of the registry rows since #810 piece E (accountingAssetBooks()), rebuilt whenever the registry generation changes; the hard-coded ACCOUNTING_ASSET_BOOKS constant that used to back it is deleted, because a map frozen at import cannot see a row an operator edited after the deploy (Y4). A token the registry does not carry (a carry-leg exotic, a PT's underlying) resolves through the rules below rather than through a static map. Pendle PT addresses are ephemeral (a fresh token per maturity) so they are never registry rows; bookForAccountingAsset resolves a PT to its underlying's book via an optional pt_address → underlyingAddress map the caller builds from pendle_markets. This matters for an Aave/SparkLend e-mode PT-collateral reserve, whose lending_reserves.underlying IS the PT address: without resolution it buckets to EXCLUDED, and because the account carries debt the WHOLE e-mode carry drops from PnL as unknown-asset. (The Pendle venue reader already emits the underlying directly, so only the Aave/Spark PT-collateral path needs this.)
Inclusion is computed at read time per position group (classifyLegsAtTs), never by skipping a write, so history stays valid when an account restructures later. It is computed per snapshot ts and resolved into time spans by computeViewSegments (segments.ts, M22), so a structural change moves a position from that tick forward and leaves the history it already accrued where it was. Grouping (groupKeyForLeg): Aave/Spark = the whole account per protocol (pooled cross-collateral); Morpho Blue = per market; vault shares and PTs = single-leg groups; Fluid = per NFT (the position_key's fluid:vault:<addr>:nft:<id> prefix — the first five colon-parts — so a 6-part T1 leg and a 7-part smart leg collapse onto one NFT, up to 4 legs for a T4). The rules:
- (a) a group with no debt legs is included, per-leg, by each leg's own book (a debt-free Aave account holding a USD supply and an ETH supply charts each in its book). A leg whose asset settles in no currency book at all is served with a category and a value but charts in no currency view, so it is listed in the All view alone;
- (b) an Aave/Spark account is judged as a whole first, because its collateral is pooled. Supplies the holder has NOT enabled as collateral are carved out first and judged on their own (#717 D2); they secure nothing, so they never travel with the account. What is left is then tested in one order: an account with debt and no collateral at all is a bare debt (rule e); an account every one of whose collateral legs is an unpriceable principal token has no statable collateral either (rule f); an account through which ONE currency book does not run on both sides is cross-asset, and the whole account moves to the All view for as long as that holds (M22); only while none of those holds is the account partitioned by book (the per-book carve-out, coverage-expansion plan rule 6) and each real-book sub-group gated on its own: a sub-group with debt and same-book supply is a carry regardless of e-mode (settled 2026-07-15 — the e-mode gate is dropped; the supply/debt coupling within one book, not e-mode, is what makes a same-book loop honest to chart), and a supply-only sub-group charts as debt-free. So a USDC supply charts in USD even while the same account runs an ETH carry. WITHIN a book, supply and debt stay coupled (a loop never charts its collateral yield free of its funding cost); ACROSS books nothing was ever netted, so the partition loses no cost attribution. Since 2026-09-18 a leg whose asset has no currency book is a member of the account rather than a verdict of its own: enabled as collateral it makes the account cross-asset and is listed inside it, not enabled it is its own repo lending row.
emode_categoryis still stored and denormalized onto every leg, but only as the display E-MODE chip (PositionRow.emode), never as an inclusion gate; - (c) a Morpho Blue market group's collateral + debt legs move together: one currency book through both of them is a carry in that book, anything else is cross-asset, and the two categoryless verdicts (rules e and f) come first. The pure-lend supply leg (
morpho:market:<id>:supply) is carved out and judged on its own book (coverage rule 5: lender-side funds are never seized, so a direct lend is economically independent of the wallet's own borrow in the same market) — including when that book is none at all, which makes a bitcoin pure lend repo lending in the All view rather than an absence; - (d) a Fluid NFT group is judged off the base set of each side of its VAULT, resolved once over the whole history in scope rather than per snapshot (M14), with no e-mode gate (Fluid vaults are isolated pairs by construction). With debt: one currency book running through both sides is a carry in that book, anything else is cross-asset. Debt-free: the NFT is judged on its collateral side alone — exactly one currency book charts it in that view, anything else (two books, or a pair holding an asset with no book) lists it in the All view at value. Either way the whole NFT moves together, and a leg on a seven-part smart key carries the category
smart_repo_lendingrather thanrepo_lending. Fluid legs chart by these rules since FWS3 landed the flow scanner (see M14). - (e) a bare debt is never charted, on any venue. A debt-bearing group holding no collateral leg — none observed at that ts and none carried into it — is excluded as
cross-book, exactly as the Aave/Spark partition once stranded its bare-debt sub-groups (rule R2): charging a book with a funding cost whose asset side is nowhere in view is a misstatement on its own, and a liquidation landing in that stretch would book its debt write-off as an unexplained equity gain. The shape arises two ways — a collateral row that failed to read for more than a single tick, and residual bad debt after a seizure that took all the collateral. It carries no category, which is what puts it in the Not covered band, outside the All view's total and outside its value history; - (f) a debt-bearing group every one of whose collateral legs is a principal token whose payout asset creddit does not track is the same verdict by a different route (
payout-asset-not-tracked): the collateral is read and held, but there is no unit to state it in, so the borrowing has no value the product will publish. Also categoryless, also Not covered. A group mixing such a token WITH a priced collateral is cross-asset instead, and the untracked leg is listed inside it with its value withheld — which is what keeps a borrow from ever being published with nothing standing behind it.
Reading a structure into an absence. A leg missing from ONE snapshot is a failed balance read, not a capital move (M9), so every rule above is given the books the group was observed to hold on each side both immediately before and immediately after the ts (computeViewSegments, CarriedPresence). It is deliberately two-sided, because both directions cost: a dropped supply row reads a carry as a bare debt (rules b and e), while a dropped debt row reads a financed position as an unlevered one and charts its gross yield in a plain view. And it is deliberately adjacent-snapshot only — across a longer hole nothing here can tell a read outage from a genuine full repay or withdrawal, which is a question only the flow ledger could answer and the classifier never sees it. The residual is stated in M22.
Everything else is valued, returned by the positions API and never charted into a currency book, and since the 2026-09-18 taxonomy it is listed in the All view under the band its own shape earns: a lend is repo lending, a supplied pool pair is smart repo lending, a bare balance is idle or variable rate, a borrowing is a cross-currency borrowing. Only a position the product cannot value is set apart, under Not covered, and only that band sits outside the All view's total and value history. Before v0.29.0 none of it rendered at all (the 2026-07-13 rule that hid it is superseded); between v0.29.0 and 2026-09-18 all of it rendered in one band called "Not in the USD or ETH views" at market value only. The WS8 unknown-asset alert remains the surfacing path for a token nobody has classified yet. Between 2026-08-06 and 2026-09-16 that section ALSO carried what the pipeline never stored: a large bare balance of a token outside the tracked wallet-token registry (stETH, eETH, then the bitcoin wrappers) was read at display time and DISCLOSED there at market value in USD. It was a disclosure and not coverage — those balances entered no snapshot, no flow ledger and no curve — and the case for reading them at all was that the alternative was silence: one wallet's 12,064 stETH, 42% of everything it owned, appeared in no book, in no outside group and in no alert. BOTH POPULATIONS HAVE SINCE GRADUATED and the path is deleted. stETH and eETH became tracked share-accounted assets at #810 R5, and the 2026-09-16 coverage rule swept the rest: having no base stopped being a reason not to read a balance. Every one of them is a stored leg now, on the value line as well as in the total, which is what retired the card's "priced live and has no history yet" caption along with the module. The original reason for NOT charting the FIRST two is worth keeping, because it is what R5 had to solve rather than waive: stETH rebases, so the balance grows with the staking yield and emits no transfer, the flow ledger cannot separate earning from receiving, and the accrual line (redemption-rate growth, and stETH redeems 1:1 for ether) would report zero beside a quantity that visibly grew. The machine reason (InclusionReason) is one of debt-free / same-book-carry / same-book-debt / cross-book / cross-asset / unknown-asset / payout-asset-not-tracked (which since 2026-09-17 reaches the page only for a PT posted as COLLATERAL — a bare holding of one is not shown at all, see Portfolio). Only two of them are ever rendered, and both name a borrowing this product cannot value: cross-book ("Debt without matched collateral") and payout-asset-not-tracked ("Payout asset not tracked"), the whole of the Not covered band. Every other holding is listed under a category of its own and needs no sentence explaining its absence from a currency view, which is what retired the old reason chips. One-line rule for users: supply positions always count, whatever they settle in; a borrow through which one currency runs on both sides is a carry in that currency, e-mode or not; every other borrow is a cross-currency borrowing, stated at value with both sides and a net; a borrow with nothing statable behind it is not covered. Two members are vestigial on the read path and kept for the wire alone: directional-pair was deleted on 2026-09-18 with the Fluid directional verdict it named, and unknown-asset is produced by no path any more — a holding whose asset settles in no currency we report in is now filed by what it IS. cross-book is reached by one live path: a debt-bearing group — isolated market, Fluid NFT or whole Aave/Spark account — holding no collateral observed or carried. The per-book bare-debt SUB-group arm is kept in the Aave partition as a defensive residual and is not reachable through the whole-account test, since a debt in one book beside a supply in another makes the account cross-asset before the partition runs.
Category (the product-facing taxonomy). Alongside book (the price-isolated PnL partition), the classifier derives a second read-time classification — the Category returned on each PositionRow — from the SAME structure, so the two can never disagree. The eight categories, in display order, are repo_lending (a lend with no borrow against it: an Aave/Spark supply-only sub-group, an uncollateralized Aave supply, the Morpho supply carve-out, a Fluid Lending fToken, a debt-free Fluid vault NFT on a plain side — whatever currency it settles in, and in no currency at all), smart_repo_lending (a supplied Fluid DEX-pool pair with nothing borrowed against it, recognised by the seven-part smart position key; it is the pair that is the position, so the two pool-token legs render as one row), carry_trade (any collateral+debt group one currency book runs through on both sides, e-mode or not, AND every leg of a cross-currency borrowing, which is a carry whose two sides do not settle in one currency), money_market_fund (a curator ERC-4626 vault, role: 'curator' — the curated Euler funds PLUS, since T5 §3.5, the exhaustive MetaMorpho FACTORY universe, so a deposit in any MetaMorpho vault charts here whether or not it is on the curated Repo lending page), managed_strategy_fund (a multi-strategy fund, role: 'managed'), fixed_rate_asset (a bare Pendle PT), variable_rate_asset (a bare wallet balance of a yield-bearing profile asset — wallet venue, T2), and idle (an unproductive bare balance — wallet venue, T2). The category is derived from the venue, the reason and the leg's shape, independently of whether the leg is charted, the way wallet legs always worked: a holding no currency view can report a return for keeps its category and is listed in the All view, rather than being named as an absence. The lending-venue category follows the inclusion reason (debt-free → repo_lending or smart_repo_lending, same-book-* → carry_trade, cross-asset → carry_trade); the erc4626 category follows the vault's role; a bare PT is fixed rate; the wallet venue's variable_rate / par token class drives the last two. A variable_rate wallet token (sUSDe, reUSD, wstETH, …) carries no venue index — its share-rate appreciation rides the accounting-asset→book redemption rate on the value series, exactly as the same wrapper held as Morpho collateral. Being a value-accrual leg (legIntervalYield = Δvalue − netLegFlow), its every balance change MUST be a flow in the ledger, or the acquired principal books as phantom yield — so the registration backfill replays the wallet-venue flows over history (taxonomy T3: ERC-20 Transfer replay; native ETH's movements, since issue #966, from the block feed's transfers and fees and WETH9's wrap and unwrap, where they were balance-diff anchors before), not just the forward 6h cron. A par token has yield ≡ 0 by construction, so a par idle balance charts as a flat zero-yield line in its book (native-ETH transfers still book as flows there, to keep the invariant clean and surface the capital move, and its network fees as costs, even though neither touches the zero-yield curve). A token settling in no currency book (EURC, WBTC, XAUt, registry book NULL) is valued in USD (PositionRow.charted = false) and surfaced under its own category, but never blended into any book curve (an FX move is not yield). It is therefore NOT part of the hidden set — outsideLegGroups filters on category === null and such a leg carries a category — so it is covered and listed, not held out. Exactly two verdicts carry no category, and both are a borrowing the product cannot value: cross-book (a debt with nothing standing behind it) and payout-asset-not-tracked (every collateral leg a principal token whose payout asset is untracked). They are the whole of the Not covered band, which is listed but sits outside the All view's total and its value history. A bare PT on an untracked payout asset is categoryless too and is hidden outright (2026-09-17). An Aave/Spark bare-debt sub-group is not in that set: it makes the account cross-asset, and every leg of the account, bare debt included, is a carry_trade (M22). SummaryResponse.books[].categories rolls each category up within a book (value, net yield, realised APY) — books are the accounting dimension, category is presentation, and the roll-ups never sum across book units.
managed_strategy_funddisplays as "Multi-strategy funds". The category was renamed because "managed" did not separate it from money market funds, whose Morpho/Euler curators are also managers. The separator is mandate breadth: a multi-strategy fund's manager may take leverage and directional exposure, while a money market fund's curator only allocates across lending markets — which is also why the two roll up separately here. Only the display label moved. The category keymanaged_strategy_fund(and the erc4626role: 'managed'behind it) is deliberately unchanged: the key is the/api/portfoliowire format, so renaming it would break any client bundle still running the previous deploy for the length of a rollout, and it buys nothing a user can see. Labels live inCATEGORY_LABEL(src/lib/portfolio/api-types.ts); keys do not move.
M2 — window yield from (qty, index) pairs
For one index-bearing leg held flat (no flow) between two snapshots at the same position_key, the accounting-asset value grows by exactly the ratio of its compounding indexes, so:
segmentYield = baseValue × (indexRaw_end / indexRaw_start − 1)where baseValue is the leg's value at the window start (segmentYield in pnl.ts). Because baseValue = qty × indexRaw_start (descaled), this equals qty × (indexRaw_end − indexRaw_start) descaled: the yield actually earned on the capital present at the window start, in the leg's native unit. The venue's fixed-point index scale cancels in the ratio (indexRatio scales to 18 digits in bigint first, so a ~3e-5 6h growth keeps full precision). Capital added mid-window earns from its own flow block and is picked up in the next window (a deliberate, documented 6h-granularity approximation that keeps flows strictly out of yield).
The composed index (wrapper legs). The invariant attributedYield == ΔbookValue (over a flow-free window) holds only if indexRaw is proportional to the leg's value in its book unit, not merely in its accounting-asset unit. When the accounting asset IS the book's unit (WETH in ETH; USDC and the base stables in USD; WBTC/cbBTC in BTC) the two coincide and indexRaw is the bare venue index. But when the accounting asset is a yield-bearing wrapper whose book differs from its own redemption base — wstETH/weETH/rETH supplied on Aave/Spark (ETH book), sUSDe / sUSDS on Aave (USD book), and the same tokens owed as debt — the book value carries a second growing factor, the wrapper's redemption rate:
value_book(t) = qty × venueIndex(t) × wrapperRate(t)so indexRaw must be the composed indexcomposeIndex(venueIndex, rate) = venueIndex × (accounting-asset→book-unit redemption rate), both read at the same block (src/lib/portfolio/valuation.ts). Its ratio is venueRatio × wrapperRatio: the venue supply/borrow interest plus the wrapper's staking/redemption appreciation — the two additive components of a wrapper leg's within-book yield, matching the carry-leg composition rule above ("wrapper appreciation PLUS the venue supply interest"; on the debt side "the venue borrow rate PLUS the debt token's own wrapper appreciation"). Feeding the bare venue index instead attributes only the venue interest (≈0 for an LST reserve) while the book value climbs at the staking rate, so the cumulative-yield line and the value line diverge with no flow to explain it and the flagship wstETH/WETH e-mode carry reports ~0 net carry. The composition is enforced in the types: LegSnapshot.indexRaw is a branded ComposedIndex whose only producer is composeIndex, so a bare venue index (a plain bigint, e.g. a reader's PositionRead.indexRaw) is a compile error. Degenerate case: a book-unit accounting asset uses IDENTITY_RATE (1), so the composed index reduces to the venue index and nothing changes for those legs. The rate follows the leg's accounting asset, never the mere fact that a leg is an ERC-4626 vault share: for the curator vaults and the par-stable Fluid Liquidity Layer fTokens (fUSDC/fUSDT/fGHO/fUSDtb) the accounting asset IS the underlying par stable, so its rate is IDENTITY_RATE and the share token's own convertToAssets index carries all of the yield with no double-count. A vault whose underlying is itself a non-par wrapper composes the underlying wrapper's rate on top of the vault index — the vault index carries the vault's excess yield in wrapper units and the wrapper rate carries the wrapper→base appreciation, two distinct sources, so composing both is correct rather than a double-count. The full fToken set (T4 §3.5) makes this concrete: fwstETH (accounting asset wstETH, ETH book) composes the vault's convertToAssets with the wstETH getStETHByWstETH getter, and fsUSDS (accounting asset sUSDS, USD book) composes it with the sUSDS convertToAssets getter, while fWETH (WETH par) stays IDENTITY_RATE. Verified live: an fwstETH lending position charts in the ETH book with both marks (~1.98 ETH), the wstETH redemption appreciation attributed on top of the fToken's own accrual.
Resolving the two rates (WS4). The composed index needs one number per leg — the accounting-asset→book-unit redemption rate — resolved in src/lib/portfolio/valuation-sources.ts (resolveBookUnitRate) through the token's own registry row: a block-pinned on-chain getter read at the leg's block (wstETH getStETHByWstETH, ERC-4626 convertToAssets, an LST getRate/getExchangeRate) in BOTH the history and the "now" mode. Where that read comes back null, the stored 6h token_yield_apy.share_rate series answers instead — for anything that is not a LISTED FUND, whether or not the row declares rate_kind = 'series'; the gate is isListedFund, not a column, so the series both carries the declared case (sUSDai, whose ERC-4626 accounting lives on the Arbitrum hub and has no Ethereum block to be read at) and absorbs a transient archive failure on an ordinary wrapper. A listed fund takes no series (R3): its value IS its NAV per share, so a rate six hours from the leg's own block is a different number rather than an approximate one, and a fund whose getter cannot be read is skipped. The classification is pure (redemptionRateKind in valuation-math.ts) and it reads the registry rather than a list: a par accounting asset resolves to IDENTITY_RATE, and the par set IS the registry's class = 'par' rows plus the two par-rebasing bases stETH/eETH — which carry class = 'variable_rate' deliberately, because one stETH SHARE does not redeem at par even though one stETH TOKEN does — minus the bitcoin wrappers, whose par was par to the retired BTC book and is not a claim on any book creddit still reports in. Everything else in a real book is a rate-source wrapper whose rate is READ; a genuinely yield-bearing wrapper with no honest rate source throws and the leg is skipped (M9) — never defaulted to 1, which would report its appreciation as $0. That last population is empty today: eBTC, its only member, left it with the BTC book and has NO base, so its leg resolves EXCLUDED and is valued at market in dollars in the All view rather than skipped. The weekly sync-portfolio-tokens.ts (T6) ASSERTS this stays true: every wallet-tracked variable_rate row that has a base must resolve a rate source (a block-pinned rate_getter on its own registry row OR a token_yield_apy.share_rate row), and a new unresolved one fires a WS8 alert so a silently-skipped bare balance is caught before it ships. The scope is the skip's own: a leg with no base never reaches the branch that skips, because redemptionRateKind short-circuits EXCLUDED to unknown before the rate is asked for, and the leg is stored at quantity × its market price with a NULL redemption mark. So eBTC, apyUSD, sUSDat, wFalconX and AA_FalconXUSDC are swept with no rate source and none of them pages (2026-09-16). A leg's two mark values (resolveMarkValues, pure) are the descaled accounting-asset amount times, for REDEMPTION, that same on-chain rate (so an index leg's redemption value is exactly proportional to its composed index and the invariant holds to float epsilon) and, for MARKET, the secondary-market price in book units (the Dune price mirror, batched once per snapshot via loadMarketContext; per-timestamp price loops 429 at scale). The invariant therefore holds exactly in the REDEMPTION mark and only up to wedge drift in the MARKET mark, since market price and redemption rate diverge by the wedge.
A none-accrual leg (a non-yield-bearing raw Morpho collateral: WBTC, cbBTC, or a plain stable, marked at par in its own book) earns no yield and contributes 0. A yield-bearing wrapper held as raw Morpho collateral (e.g. sUSDe collateral in a sUSDe/USDC market) is instead a value-accrual leg: Morpho pays no interest on collateral, but the wrapper's own redemption appreciation (its share-rate growth) is real within-book yield, attributed from the mark's value series net of flows (v1 − v0 − netflow, the same machinery as a Pendle PT), exactly as when that wrapper is held directly via the erc4626 reader. Valuing it none would silently drop the entire collateral yield of a same-book Morpho carry (the same wrapper appreciation the carry-leg section above marks as the Morpho carry target).
M3 — flows, per-tx netting, and TWR
An external flow is capital entering or leaving a book, detected from events at block precision and valued at its flow block. Flows are netted per tx hash so a leverage loop is not read as a deposit. Each movement gets a signed contribution to the book's net value (signOf, src/lib/portfolio/v2/classify.ts): deposit / repay / transfer_in / income / internal_in are +value; withdraw / borrow / transfer_out / cost / internal_out are −value. There is no liquidation kind: a liquidation is a seizure and a debt_relief, and both sit OUTSIDE the total-return flow set (M4). The two records the reading audit writes, opening and adjustment (M31), carry no constant sign: each is signed by the leg's side and the direction its quantity moved (flowSign), and both sit INSIDE both flow sets, so neither is ever booked as earned. Which movements count as the reader's money, and at what scope, is M31 (inCapitalSet, v2/capital.ts); netting them per transaction is netByTx (v2/markers.ts), which collapses a recursive loop (borrow X → swap → supply X) to ~0 external flow while a genuine $100 deposit that is then looped nets to +100.
A trade between two of the reader's own holdings is not a flow, and its cost is not capital. Where one transaction pays out one covered asset and takes back a different covered asset of the same book, and no venue explains either side, the two movements are one exchange: nothing entered the wallet and nothing left it. Both sides are booked as internal movements of one group, so neither counts as the reader's money at any scope, and the asset that arrived is valued at what was paid for it rather than at the price a pool quoted at that moment. That second half is what puts the trade's cost on the total-return line: the gap between what was paid and what the arriving asset was quoted at is booked as a loss on the leg that received it, once, at the trade's own block. Nothing is stored as a fee and nothing is netted twice — the cost is the same subtraction every other figure on this page is, and the received asset's later price move measures from the price actually paid, so the trade cannot be charged again.
It reaches the market line and not the price-free one, and that is a decision. A trade's execution cost is what the market charged to move between two claims; it is not what either claim produced by being held, which is the only thing the accrual line is about (M24, whose one structural exception this is deliberately not a second of). So the arriving asset's redemption mark stays its own, and a wallet that buys a covered asset above its redemption value and holds it prints nothing on the accrual line for that day — the same as before this rule existed.
Measured on the movement that produced the rule: a wallet bought a savings token in the morning and sold it back the same evening, for 65.34 units of par money and $63.47 at the two trade blocks' own marks — 15.67 basis points. Both sides were recorded as capital arriving from and leaving to strangers, so the cost netted away, and the position opened and closed between two daily readings so no reading at either end could see it. The day was published at +$4.23, which was exactly the price drift on the remaining balance after the second trade, standing alone. It now reads −$59.24 on total return: the trade's own cost, plus that drift. On the accrual line it reads the redemption value the savings token accreted in the eleven hours it was held, and nothing else.
Three shapes are deliberately outside the rule, because each is a real movement of capital and reading it as a trade would move a published number in the direction nothing else can catch: a transfer out with nothing coming back (money genuinely leaving), an exchange whose two sides sit in different books (a dollar sold for ether really does leave the dollar book), and any movement a venue, a rewards campaign, another of the reader's own wallets or the poisoning heuristic already speaks for. A transaction carrying more than one candidate on either side is also left alone rather than paired on a guess. A cross-book trade's cost is therefore still uncharged on either line — the defect this rule closes survives there, because charging it would mean blending two books, which M22 forbids.
The interval is a block range; the timestamp is only its label
An interval is the half-open range of block numbers (b0, b1] between the two consecutive spine reads that bound it, and a snapshot's snapshot_ts is a presentation label rather than the instant the chain was read. A flow belongs to an interval when its own block satisfies b0 < block ≤ b1, and never by comparing timestamps. This is exact rather than merely closer: a read at block b returns the state after b is applied, so the closing value already contains a flow at b1 while the opening value does not, which places every flow against exactly one interval and against the two endpoint values that do and do not contain it.
The distinction is not academic. A stored snapshot is filed under the aligned 6h window it belongs to, while the live writer's read of the chain runs up to about fifty minutes after that label, so there is a band after every window label during which a flow is already inside the value that window reports even though its timestamp says it belongs to the next one. Measured on production on 2026-08-18, 57 of the 365 real stored flows sit in that band (15.62%), carrying $487,722.57 of $3,868,607.28 of notional (12.61%): about one flow in six and one dollar in eight. A flow in that band is netted, under a wall-clock test, against a value step that never contained it: the interval that did contain it books the whole movement as yield, and the interval after it books the same amount back out as a loss. Both lines of the chart, the per-position "yield earned" column and the view a flow is charted in all key on the block for this reason. Because the key is the block, the placement also survives a backfill re-stamping a stored row's timestamp.
That count is over real flows only. The remaining 84 of the 449 stored rows are the native-ETH window-diff synthetics (tx_hash = 0xeeeeeeee…), which carry the aligned window label as their timestamp and that same window's read as their block. They therefore sit at offset zero by construction, are all "in band" trivially, and bucket into the same interval under either convention, so counting them would report a band rate near one in three that measures how those rows are written rather than anything about the spine. That artifact is called out in the data contract (§6.7) and is why the figure above is quoted over the real population.
How much of that is on the board today: almost none, and the change is forward-looking. The band is a property of the live writer's lag. The stored history is still overwhelmingly on the backfill spine, whose reads sit about twelve seconds past their own daily label, so no stored row is far enough inside a band to cross an interval boundary: measured on production on 2026-08-18, 0 of all 449 stored flows bucket differently under the two conventions, real rows and synthetics alike (446 of the 449 carry basis = 'backfill', and the 6h live spine only begins at 2026-08-12 12:00). The value of keying on the block is for every flow the live writer buckets from here on, which is also why the convention is pinned by fixtures (src/lib/portfolio/block-buckets.test.ts) rather than by a diff of stored history.
The cumulative-yield chart is the running sum of each leg's booked interval earnings (buildCurve, src/lib/portfolio/v2/attribution.ts → points[].cumulativeYield), net of flows by construction (a flow is subtracted inside the law, so a top-up cannot read as profit). Any percentage uses time-weighted return (linkTwr):
TWR = Π(1 + intervalYield_i / bookValue_start_i) − 1geometrically linking each interval's return on the book's net value (equity) at the interval start. Intervals with non-positive equity are skipped (no defined return, no divide-by-zero). A leveraged book therefore reports its return on equity, which is the honest realised figure for a carry.
Because the link is a product rather than a sum, a bucket change in the middle of a history moves twr and realizedApy even where the dollar totals are untouched. Mis-bucketing a capital flow of amount A adds +A to one interval's yield and −A to the next, and the two are divided by different starting values, so they do not telescope. Where V0 and V1 are the two intervals' starting equity and y1 the later interval's own yield, the exact difference is A · y1 / (V0 · V1), which can fall either way with the sign of A and of y1. For a capital flow the dollar lines (totalYield, totalReturn, cumulativeYield) do telescope and are unchanged, and a seizure telescopes the same way: it is a flow on the accrual line and a value move on the total-return one (M4), so neither dollar total depends on which interval it lands in. realizedLoss is the figure a mis-bucketed seizure can move, and it can move it to zero: the sum admits a receipt only from an interval one span of the curve holds end to end, so a seizure bucketed outside those bounds is dropped rather than mis-attributed.
M4 — liquidations are realized losses, never withdrawals
A liquidation is a valuation event, never a withdrawal. The collateral leaves the position, but the reader neither took it out nor chose to: reading a seizure as a withdrawal would net it out of the flow arithmetic and silently remove the loss from performance. That is the rule. What follows is how the served engine keeps it, which is not how the retired one did.
Two rows, one event, and no penalty is ever stored. The derivation writes the venue's own movements: a seizure on the collateral leg and, where the liquidator cleared debt for it, a debt_relief on the debt leg, sharing one link_id under link_kind = 'liquidation' (data contract §2.4, §6). There is no kind = 'liquidation' row, no penalty column, and nothing anywhere that holds "the equity destroyed" as a stored figure. The Aave/Spark protocol fee is its own seizure row on its own log index rather than folded into the collateral one, so its absence is detectable instead of invisible.
The two lines read those rows differently, in one pass (V2_ACTION_RULES, src/lib/portfolio/v2/classify.ts):
seizure | debt_relief | what the line books | |
|---|---|---|---|
| Total return (M23) | not a flow | not a flow | both value moves are return, so the line books seized minus repaid, exactly once |
| Accrual (M24) | flow | flow | both movements net out, and the interval keeps ordinary interest |
With the engine's law (earnings = sign(ℓ)·ΔV − Σ signed(r)·A(r)), collateral falling by S and debt by R gives total return (−S) + (+R) = −(S − R) and accrual 0. There is no max(…, 0) floor on it: for an underwater position the true value move is a gain, a liability was extinguished, and flooring it at zero leaves equity moving −20 → 0 with nothing booked. Writing the liquidator's repayment as an ordinary repay instead is the classic way to get this wrong — M25 carries the measurement: a repay IS in the total-return flow set, so it nets out, collapsing −(S − R) to −S, which on three real seizures drew 19 to 20 times the true loss.
What the yield line does and does not contain. The cumulative-yield line does not have the equity destroyed subtracted out of it. The retired engine's intervalYield -= loss went with the engine; on the accrual line a seizure is a flow and books nothing at all, and on the total-return line it books the netted penalty once, as a value move like any other.
realizedLoss is a separate sum, beside the lines rather than inside them.seizureLossOf (src/lib/portfolio/v2/attribution.ts) adds legSign × −valueMarket over every seizure and debt_relief receipt whose block falls in an interval one span of that curve holds end to end, PLUS the cross-book netting the same interval's booking carries (M25). An unpriced row contributes nothing rather than a zero (M9), and a receipt in an interval the curve does not own end to end is left out rather than attributed to a stretch the position was not in. It is a presentation SPLIT of the number the line moved by, never a second subtraction, which is what stops the tile and the series disagreeing about one event. So an interval the engine DECLINED to book contributes nothing here either, whatever rows fall in it: where the line moved by nothing the tile has nothing to split, and without that gate a withheld interval published the very figures the withhold exists to refuse. It is a live restatement rather than a hypothetical — on the deterministic fixture one wallet's dollar-book tile goes from −31,200.66 to 0, on a seizure interval its series had always booked 0 across.
The annualised market rate carries the seizure, and that is an open behaviour. The positions table's two realized-rate columns are two annualised time-weighted returns, one per line: realizedApyRedemption annualises the ACCRUAL TWR and realizedApyMarket annualises the TOTAL-RETURN TWR (attribution.ts, linkTwr → annualizeTwr, published in ledger-v2-api.ts). Because the seizure is on the total-return line, one liquidation in month two of a position still becomes an annual rate, and that half of #522 is still open — the half it asked for, keeping the seizure off the accrual line, is what the table above already does. What #522 measured — +168.5% on the fixture's liquidated Morpho debt leg — was the cross-book phantom being annualised rather than the annualisation itself; with the netting in place that leg serves a small negative rate (its own financing cost), and the remaining question is what a REAL penalty does to an annualised figure over a short observed span. Both rates are withheld entirely over an impairment window (§2.6.12 rule 2) rather than floored or clamped: a −22.47% index step in one block is a real loss the cumulative series carries, and annualising it over six hours publishes a nonsense rate.
Where the collateral and the debt are in different books, the law's own subtraction cannot reach across them and the pair is netted before it is published. The write-off is carried into the collateral's book at the liquidation block's own marks, the collateral's curve books −(seized − converted repayment) once, and the debt's curve books nothing for the pair. The rule, the rate and the refusals are M22's carve-out and M25.
The venue mechanics need no exclusion rule. An Aave or Spark liquidation burns the borrower's collateral aToken and debt vToken and moves a fee on the same transaction, and the retired scanner recorded those position-token transfers as ordinary withdraw / repay / transfer_out flows, which then had to be stripped by a same-transaction rule. The derivation instead BINDS each record to its role from the venue's own liquidation event before any generic consumption runs, so the mechanics are never written as separate movements and there is nothing to exclude. Fluid has no position-level liquidation event and is classified by state diff (M15 below): a vault LogLiquidate triggers a per-NFT diff, and the read core is liquidation-safe on its own — isLiquidated=1 means a position sits on a liquidated branch, not that it is closed.
M5 — two marks (MARKET vs REDEMPTION)
Every snapshot and flow is valued in both marks: MARKET = the Dune price mirror (token_price_bars) secondary-market USD bars; REDEMPTION = on-chain redemption/share rates (share rates, liquidity indices, exchange prices; ETH wrappers without an on-chain rate are par by convention in REDEMPTION). Protocol oracles are never used for PnL marks, and DeFiLlama is not in the mark pipeline (Milestone C removed it). A curve is computed in one mark end-to-end, and a flow is valued in the same mark as the line it adjusts — the engine walks both lines in one pass and each reads its own value column throughout (v2/engine.ts, v2/attribution.ts). The two marks are fully independent: the yield in each scales with that mark's own base value.
The two marks are no longer a reader's choice. They are the raw material of the two performance lines: total return (M23) is mark-to-market by definition, accrual (M24) is the redemption-basis attribution, and neither is a valuation the reader picks. Both marks stay fully wired, because the per-position surfaces below (redemption value, basis, dislocation since entry) read them directly.
M5.1 — MARKET book-unit prices divide two SAME-BAR mirror quotes
The decomposition that makes a mark coherent:
market(block) = (1 + basis) × redemption(block)Basis (the secondary-market premium/discount) is the slow variable (bps/day outside a depeg); redemption is exact at any block from the chain. All the historical noise came from re-measuring the full market price as a ratio of two fast-moving USD levels sampled at not-quite-the-same instant. A MARKET value in the ETH or BTC book is such a ratio (tokenUsd / wethUsd, or / btcUsd), and the USD legs cancel only if both quotes are from the same bar. The Dune price mirror stores same-bar USD quotes, so priceInBook(token, ts) = bar(token, ts).usd / bar(numeraire, ts).usd is coherent by construction — the ETH/BTC level cancels exactly, leaving only the slow basis. loadMarketContext (src/lib/portfolio/valuation-sources.ts, via mirror-prices.ts) exposes a per-asset priceInBook map (book units per 1 accounting asset); every ETH-book consumer reads priceInBook, never usd divided ad hoc.
Newest-common-bar rule. For an ETH-book asset the reader takes the whole covering-bar window (
getBarSeriesAt: every bar in[align(ts) − 48h, align(ts)],BAR_STALE_HARD) for BOTH the token and its numeraire and divides them at their newest commonbar_ts(pairAtNewestCommonBar, shared with thetoken_basisrefresher). It never divides a token bar by an independently walked-back numeraire bar — that re-leaks the cross-bar ETH/BTC move, the ±30–50bps of noise this refactor deletes. The USD book is a single quote (the token's own newest bar, no division); the book's own unit (WETH / the ETH sentinel) is identically1with no read.Same bar AND same vendor, since the pricing-categories release gave the tape a second and third writer (an hour Dune never printed is filled from CoinGecko, then DefiLlama — History fill). What cancels in a ratio is what the two quotes SHARE: two vendors share their observation of the market and differ by their own bias, so a wrapper bar from one vendor over an ETH bar from another reports that bias as basis — a few bps on a number that is itself tens of bps, arriving as a one-hour step and reversing at the next grid point, far below anything the spike gate looks for. So an hour whose two legs carry different vendors is not a common bar, and the pairing walks back to one whose legs a single vendor wrote (Dune's hourly tape and its dex-ratio query are the same vendor). No such hour in the window fails the token, which is exactly the answer a hole gave before the fill existed. This is the coherent-but-lagged doctrine below on a second axis.
The
≈ $Xdisplay annotation is explicitly OUTSIDE this rule. In the ETH book the signed-in Portfolio prints a muted USD equivalent under its headline figures (usdEquivalent, priced byGET /api/portfolio/prices). That isbookAmount x the latest numeraire price— a cross-vintage multiply, exactly what the newest-common-bar rule forbids INSIDE a mark, since the book amount was itself struck against its own same-bar ratios over the life of the book. It is admissible only because it is a labelled display annotation on an aggregate: no mark, basis, snapshot or stored figure reads it, the hero tooltip names the price and the moment it was struck at, and an unavailable price renders NO line rather than a fabricated one (M9). Applied to a cumulative yield it answers what the earned ETH is worth today, not what it was worth as it accrued. Nothing in the valuation path may take a price from this route. Since the pricing-categories release the number is a live vendor level where the band can be applied to one and the newest stored bar where it cannot, through the same band as every other live level (Live prices). The tooltip states two things about it and neither is inferable from "a vendor answered": the moment the price is OF, which is the serving vendor's own observation stamp and never the moment of the page load, and what CHECKED it — a second vendor, the asset's own most recent stored price, or nothing at all. A stored price described as a current one, and a confirmation claimed where no second source was consulted, are the two misstatements this line exists to avoid.Derived-base rate alignment. stETH and eETH have no raw bar slot. Their PER-TOKEN market marks divide the selected wstETH/weETH book-unit bar by the newest stored wrapper share-rate observation at or before that selected
bar_ts. They never divide an older market bar by the share rate at a later valuation block. A missing bar-aligned rate produces a null market mark rather than a fabricated cross-time ratio. Their PER-SHARE mark takes no rate at all (one share is one wrapper token, M5.3), so it has no alignment to get wrong and no rate to miss.Coherent-but-lagged doctrine. Pairing at an OLDER common bar is CORRECT even when the numeraire has fresher bars the token lacks: a ~1–3bps basis drift per 6h from a slightly stale bar is far smaller than the ±30–50bps a cross-bar ratio injects, and smaller than the alternative of no mark at all. A coherent-but-lagged mark beats a fresh-but-incoherent one because same-bar pairing is algebraic in the common
bar_ts, so the ETH/BTC level cancels at ANY age — only how old a coherent pair is served moves. That cancellation is ETH-book-specific: the USD book is a single quote (no division), so a stale bar there is a stale ABSOLUTE price, not merely a stale basis — a small, disclosed tail bounded by the same 48h ceiling (a par/near-par USD wrapper moves little over hours, and the soft-stale log flags it). Two named bounds (M20):BAR_STALE_SOFT(6h) is a HEALTH threshold — a bar older than it is still served butgetBarSeriesAtlogs the age once per run so a lagging mirror is visible in ops;BAR_STALE_HARD(48h) is the absolute walk-back ceiling, beyond which the read misses (a >48h-stale mirror is a broken upstream, not a pricing source; 48h matchessyncBars' catch-up clamp and is unambiguous against Dune's ~1h normal latency).Null-on-miss backstop (M9). No bar (or no common bar) in the window — after the mirror's walk-back and one fetch-through — is
null, never a fabricated number and never a fall-back to another vendor. That leg'svalue_marketis null for that timestamp; REDEMPTION is untouched. Par assets (WBTC/cbBTC, stables) keep a real reading so a genuine depeg still shows.A mark failure is PER ASSET, never per block (2026-08). The null-on-miss rule above is about an asset with no bar; this one is about an asset whose mark threw, and it decides how far that failure travels. Flow valuation groups every movement at one
(timestamp, block)into a single mark context, and two things can fail inside it: the batched bar read, and the per-asset follow-up reads that derive or compose a mark (stETH's wstETH share rate, a non-traded wrapper'sasset()and share rate — each an archive call of its own). Either used to void the whole context, so every movement at that block was left unmarked, including assets that had priced perfectly. Now each asset fails alone:- a derivation or composition that throws nulls that asset and the pass continues to the next one;
- a dead mirror nulls every asset that needed a bar, while an asset priced by construction keeps its mark — a book's own numeraire is identically 1 and takes no read, and a wrapper composed over a par-pinned underlying consults no bar either, so neither has any dependency on the mirror to lose;
- every failure is logged against the asset, with how many blocks it hit and the lowest of them (not the first one to finish — the blocks are priced concurrently, so the one that answers first is whichever won the race, and a repair needs the bottom of the range), so a re-mark repair targets that asset instead of re-deriving every wallet that happened to move at the same block. The log names an asset only when it ended the block without a mark and a movement at that block is actually denominated in it — so the by-construction survivors above are absent from it, and so are the extra assets pulled into the context purely so another one can be derived from them. Naming an asset that kept its mark, or one with nothing to re-mark, is what makes a repair list useless. Redemption marks are unaffected throughout — they are rates, not bars, and a movement whose market mark is withheld still carries its redemption mark.
Isolating a failure is not the same as absorbing it, and the two kinds of consumer get opposite answers. Everything above is the FLOW valuation, which prices many independent movements at one block: there, one asset's failure nulls that asset and the run continues, because throwing away the movements that priced correctly helps nobody. The same mark context is also what the SNAPSHOT writers price a whole portfolio from — the 6h refresh and the registration backfill's grid point — and those bake history: a figure they write is not revisited. For them a failed read must end the attempt, not be stored as a null, so that the run is retried and the figure is derived from a successful read instead. Both still isolate per asset (a failure is attributed to the asset that caused it, and no other asset's mark is lost), and the snapshot writers then stop. So a transient source outage shows up as a run that failed and retried, never as a permanent hole in a stored series; the flow path's honest nulls are the only ones that persist, and they are the ones the failure log above names for repair.
Two tiers, never mixed in storage. Persisted writes (the 6h cron, the WS5 registration backfill, flow valuation —
mode: "history") are the mirror bars — the single stored method. Every stored figure resolves its rates in history mode at its own block, without exception (2026-08). The one place that did not was the Fluid seizure valuation, which asked for rates in "now" mode while pricing the position at a block months in the past: for a wrapper with no on-chain rate getter the fallback is the freshest recorded share rate regardless of block, so a historical liquidation was valued at today's redemption rate while the market half of the same valuation used the block's own bar. Both halves now agree on the block. This mattered more than it looked, because the seized quantity on that path is an exact on-chain state difference: an exact quantity multiplied by the wrong price is a more confident wrong number than an obviously missing one. The ephemeral JIT "now" read (live.ts,mode: "now", never persisted) takes a live vendor level for a market-priced asset and composes every redemption-priced one off it, falling back to the stored bar where no level is admissible (Live prices at the tip). What the two modes share is the METHOD: the same category decides how an asset is valued in each, and what differs is only the vintage of the number underneath it, so a live tick and a settled snapshot cannot value an asset two different ways. An aggregator quote is not admissible as a price here, however fresh: where a token's own mint and redeem are atomic and permissionless an aggregator routes through exactly that path, so its quote reports the redemption rate back as if it were a traded price — the one route a market test has to exclude. A live price comes from a vendor that quotes observed trades.mirror-coverage.test.tsrequires every market-class base to have a market-mark path and every rate-source accounting asset either to use a raw mirror bar or to be an explicitly composed non-traded wrapper.JIT settle-on-next-cron. A flow detected minutes after it happened may predate the mirror's last sync (and Dune's own bar ingestion). The JIT async valuation stores a value from the latest available bar (a lagged-but-coherent basis, ~1–3bps per 6h in calm markets, up to the 48h ceiling). What the page shows is computed at read from the newest accepted bar at or before the flow, so it settles by itself once that bar exists, which is on the hour: the mirror's grid IS the hour (see M18). No re-mark is needed for the page; the stored column keeps the vintage it was written at. This is a design decision, not a bug.
A dash rather than a number nobody stood behind (M9). Every source is asked before one is printed — a live vendor level at the tip, the stored bar under it, and for the bar itself the tape, then CoinGecko's hourly history, then DefiLlama's — and where all of them are silent or disagree beyond the band the answer is a dash rather than the freshest of several numbers. Two consequences, both intended:
- Live-tick outage. At the live tip an asset with no admissible vendor level and no mirror bar inside 48h shows a dash (null
value_market), rather than a number nothing stood behind. That takes a two-day dead upstream, not a routine ingestion lag (M20 serves any coherent bar within 48h); a stale-but-coherent mirror bar is preferred over a fresh incoherent ratio, and a bare dash is more honest than a mismatched number. When the summary aggregates a position whose DEBT leg is the null one, the headline Tracked value withholds (a dash) too, rather than silently coercing the missing debt to 0 and reading collateral-only (M9). A null NON-debt leg — the documented thin-wrapper exclusion below — keeps rendering (it coerces to 0 and understates by a small idle holding, not a levered overstatement), so it never dashes the whole book value. - Wrappers no venue prices. The five known non-traded vault shares iETHv2, yoETH, yoUSD, fLiteUSD and yvUSD compose from their underlyings under M5.2, so they never need a quote of their own. A rate-source wrapper that is neither classified for composition nor carried by any source values with a null MARKET mark (a dash) — the honest-null degradation, until the row says which of the two it is. REDEMPTION is unaffected. For such an EXCLUDED asset the All view's dollar total shrinks by that position's value (the position is still stored with a real
qty_raw; only its USD value dashes).
- Live-tick outage. At the live tip an asset with no admissible vendor level and no mirror bar inside 48h shows a dash (null
Last-interval residual. On the chart's final (JIT) interval, a
value-accrual leg's MARKET yield is a diff between a bar-marked stored snapshot and a vendor-marked "now" — a small cross-source residual (bps; both coherent and near-current) that disappears at the next 6h snapshot, when the tip is replaced by a stored bar. REDEMPTION yield is unaffected.
Live prices at the tip
The stored bar is the HISTORY and is routinely hours old; the number a holder is looking at should be current. So in the "now" mode every market-priced asset takes a live vendor level and every redemption-priced asset composes off it, while the stored-row maps beside them keep the bar — the total-return series values every point through those, so it never differences two methods (M23).
A level is served only when something independent of every price vendor corroborates it, because the failure that matters is a quote that is wrong and steady rather than noisy. The reference is the asset's REDEMPTION value for a yield-bearing token that has a book, one dollar for a USD idle claim, and its own last stored bar for ETH, WETH, the bitcoin wrappers, the volatile spot rows and the no-base rows — a rate token that accrues against nothing this product accounts in has no unit for a redemption value to be quoted in, so its own history is its reference. Inside 3% the level is served; outside it, two vendors agreeing within 1% means the move is real and is served, and a backup inside the band wins over a strayed primary. Two vendors that CONTRADICT each other — further apart than the band itself — leave the asset with no live price at all (M9 — a dash, and an alert line within six hours). Two that are merely further apart than the 1% agreement bar do not: the 1% says when an off-band move is corroborated, never what a disagreement is, and a stored bar that is hours old is left behind by any real move, so on a volatile row both vendors are routinely outside the band while differing by the little their venue mixes differ by. That, and one vendor answering off-band while the other is unreachable, are absences of corroboration rather than contradictions: the stored bar stands with its vintage stated, and the six-hourly job logs them without paging.
Two absences are treated as absences rather than as contradictions. A prescribed reference that could not be READ — a redemption rate whose chain read failed — falls back to the row's own last bar, so the band keeps applying against weaker evidence instead of switching off, and the six-hourly job counts how many rows it happened to. And a row with no reference of ANY kind (no redemption value, no par, no stored history) has no band for a vendor to be outside of: its level is served and said to be unchecked, or, where two vendors differ about it, nothing is served and the stored answer stands. Nothing is ever withheld on a row nobody can referee, because withholding there would dash a holding and page every six hours about a disagreement with nothing.
In the ETH book the wrapper's price in ether is a RATIO, and ETH's own move cancels out of it only when both quotes saw it, so the ratio is taken from one vendor within 120 seconds and otherwise keeps the stored bar.
A served level is dated by the vendor that served it. The backup vendor's gate accepts an observation up to six hours old, and the selection rule above hands it the win on exactly the day that matters: where the reference is an hours-old stored bar, a staler quote sits INSIDE the band precisely by being stale. So the level carries that vendor's own observation time and never the moment of the read, and a level whose vendor states no time at all is not dated by the clock either — the stored bar answers instead, because its hour is a fact. The mechanics, the budget and the vendor are in Data pipeline → Live prices.
Filling the tape's holes
An hour the tape never printed used to be a permanent gap: bars are append-only, so nobody could write it later. An hour older than six hours with no accepted bar is now bought from CoinGecko and then DefiLlama, and every accepted Dune bar is cross-checked against those two — retracted only when both agree with each other while disagreeing with the tape by more than 3%, which is what stops one vendor's bad hour retracting a correct bar. A retracted hour keeps its row and behaves as a hole, which the 48h walk-back covers.
A bar this leg writes itself is held to the same 3% band: where both vendors answered for an hour they have to agree before either number is written, and where they contradict each other the hour stays a hole and is named in the log. One vendor answering alone still fills the hour — CoinGecko has no listing for Ethereum PST, so most of that row's history is DefiLlama's word alone — and its number goes through the spike gate before it is written, because a bar this leg writes cannot be taken back afterwards. And because the fill keeps every row's newest bar under six hours old, a tape that has STOPPED printing can no longer reach the 12-hour dark-feed alert: a row the vendors have been carrying for a day while Dune printed nothing is named on its own line instead. See Data pipeline → Filling the tape's holes.
The one asset priced by its own DEX-trade query (sUSDe) is backed up the same way, but only while that query is failing: an hour after its newest traded price is filled from the two vendors on a tick the query could not run, so a Dune stall no longer stops its history. An hour the query answered with no trade is left to the walk-back to the last trade, because the vendors sit a few basis points from its traded prices (a median 2-3bp, measured), about the size of its real six-hour move. The bars written before this fill existed were judged once, by the same rules, in a one-off repair (Data pipeline → Re-judging the stored history).
M5.2 — derived bases and composition, in ONE function
One function answers "what is one unit of this asset worth at this block", for every asset, on both lines (src/lib/portfolio/unit-prices.ts), and every valuation surface reads it: the stored snapshot rows, the flow marks, the live tip, the re-derive and the stored-mark repair. Which branch an asset takes is its REGISTRY ROW's valuation (M26), never a code list:
| branch | market | redemption |
|---|---|---|
identity | 1 — one unit of the book's own unit, by construction. It consults no bar, ever. | 1 |
market | its own accepted bar in its own book (M5.1's same-bar rule, rejected bars skipped, 48h walk-back, null on a miss) | its rate × the underlying's redemption |
composed | rate(asset, block) × market(underlying) | rate(asset, block) × redemption(underlying) |
derived | per TOKEN market(wrapper) ÷ rate(wrapper, at the wrapper's own bar); per SHARE market(wrapper) (M5.3) | par in its own book per token; the ETH-per-share rate per share |
A fund share is the composed branch, not a branch of its own. Its value is its NAV per share times the value of what it holds (R3), which is the same arithmetic — so a listed fund gets a block-pinned share-rate getter and no price feed at all, and tETH and liquidETH stopped being marked off the Dune quotes they used to carry.
It is RECURSIVE, and that is what R3's chain-down means. iETHv2 → stETH → wstETH → WETH is four levels, and it resolves at the bottom to the ETH book's own unit. The recursion is cycle-guarded and depth-capped, and null propagates: a missing rate or a missing bar anywhere in the chain withholds every mark above it (M9) rather than substituting a number from somewhere else.
Two things it does not do, deliberately. It never fills a hole — an asset does not "fall back" to composition because its bar was missing, it composes because its row says so, which is why there is no traded-wrapper guard left to invert. And it never crosses books: rate × the underlying's price is a price only when both are in the same unit, so a row whose underlying is in another book is refused rather than multiplied.
The mirror-coverage invariant follows from the same walk: every market-class BASE asset is mirror-tracked, a composed or derived row is exempt from having a bar, and its chain must end at an identity or market row the registry carries — enforced by mirror-coverage.test.ts, generically over rows rather than over funds.
M5.3 — a rebasing token is accounted in SHARES (R5)
stETH and eETH are variable-rate assets whose LEDGER quantity is a share count. (What the page prints is the token balance; the two are reconciled at the end of this section.) They pay their yield by REBASING: the holder's balance grows every day and no transfer happens, so a token-denominated ledger has no receipt for the growth. Its running balance drifts below the chain's and goes NEGATIVE after a full withdrawal, which burns the whole balance including everything the rebase added. That is the ghost-row shape (M29: the reading and the movement record disagreeing about whether the leg is held), reached from a holding that did nothing unusual.
Counted in shares the same holding is ordinary: the share count is constant between transfers, every movement has a share-denominated log of its own, and a full exit moves exactly the shares held. The yield stops being a quantity change and becomes a rate change, which is how every other variable-rate wrapper is already accounted. The declaration is a registry fact (portfolio_tokens.unit = 'shares', with wrapper_address and rate_getter), read through one accessor (src/lib/portfolio/share-units.ts).
- Quantity. The wallet reader reads the holder's SHARES at the anchor block — Lido
sharesOf(wallet), ether.fishares(wallet)— neverbalanceOf. That is the INTERNAL unit; what the page prints is the token balance (see "What the screen states" below). - Receipts. Recorded from the share-transfer event (
TransferShares, the same signature on both tokens), never converted from a token amount by dividing by a rate. One movement emits BOTH an amount log and a share log; the ledger claims the share log and declines the amount log, so the movement is booked once. A mint (submit/ a deposit) arrives from the zero address and is a share transfer like any other; a withdrawal request transfers the shares to the protocol's queue. - REDEMPTION.
shares × ETH-per-share, read on chain at the leg's own block (stETH.getPooledEthByShares(1e18), the ether.fi LiquidityPool'samountForShare(1e18)), in both the history and the "now" mode. - MARKET.
shares × the wrapper's own accepted bar— one share IS one wstETH, and one eETH share is one weETH (verified on chain at three blocks). No division and no share rate, so a share's market mark can no longer be nulled by a missing bar-aligned rate observation the way M5.1's derived-base rule allows.
The two units coexist, and which one applies is decided by the venue. One stETH TOKEN still redeems for one ether, and that is the number a fund over stETH needs: an ERC-4626 vault's convertToAssets is denominated in the underlying's TOKENS, so the iETHv2 position an ERC-4626 reader emits — whose accounting asset is stETH — is a token amount priced at the per-token mark of M5.2. The wallet book is the only producer of a share-denominated quantity, so a leg or a receipt is share-priced exactly when its venue is wallet. Both unit prices are computed in one pass from the same wrapper bar and they agree by construction, because tokens = shares × the same share rate.
How the two lines read (M23 / M24). The leg carries no venue index, so it is a value leg like every other bare wrapper balance, and its two lines follow directly from the definitions above:
- ACCRUAL (M24, price-free) over an interval with no movement is
shares × (rate₁ − rate₀)— exactly the rebased balance growth, and exactly the yield since entry when the interval is the whole holding period. - TOTAL RETURN (M23, mark-to-market) is
shares × (bar₁ − bar₀)on the wrapper's own quote, so the gap between the two lines is the wrapper's basis against its redemption rate and nothing else. - The row shows an APY like every other variable-rate wrapper. Before R5 its redemption rate was identity, which makes the accrual line flat by construction: a quantity that never changes times a rate that never moves earns nothing, so the whole staking return was booked as zero.
What the screen states: a token balance, never a share count. Shares are the unit the ledger keeps; they are not a unit anybody holds. So no share count is printed anywhere, and the word "shares" appears in no user-facing string. Where each figure lands, exactly:
- The statement of account prints the tokens. A movement shows the token amount that moved at its own block, under the plain ticker — two transfers of the same share count, months apart, print two different stETH amounts, which is the balance growing while it is held. Growth between movements is yield; a transfer in or out is a flow, exactly as for any other asset.
- The served position states the tokens (
/api/portfolio/positions,PositionRow.quantity). - The holdings table states the balance too, in its own Amount column: the Variable rate assets section is Position, Amount, Issuer, 24h APY, Yield earned, Dislocation P&L and Value, and every variable-rate holding says how much of it is held. For a rebasing token that figure is the served token balance, so it grows with each rebase between transfers — no unit word beside it, because there is one unit. A balance the reader's history cannot state is a dash, never a zero.
The conversion is server-side, and it reads no rate. tokens = shares × ETH-per-share at the row's own block is what a wallet's own balanceOf returns, and it is the same product the valuation path already formed and stored as that row's REDEMPTION mark — because one stETH TOKEN redeems for one ether, so the mark is denominated in the ETH book and the number IS the balance. So the server serves the redemption figure as the quantity (tokenAmountForRow, share-units.ts) rather than spending an archive call per block on a page that renders dozens. A row whose share rate could not be read has no redemption mark, and its quantity is a dash (M9) rather than a share count wearing the token's ticker.
Which is also why the flip has no display window. shares × ETH-per-share and tokens × 1 are the same number, so a stETH row written before R5 and one written after it state the same balance, and a stored row needs no marker saying which unit it is in. The stretch between the release's deploy and its re-derive is therefore invisible to a reader. The rejected alternative — multiplying the stored quantity by the rate — would have overstated every pre-release stETH row by the whole share rate (~24%) for the length of that window.
The unit is a property of (venue, asset), and only the server holds it. A fund's leg or receipt over stETH is denominated in stETH TOKENS already, so it passes through unconverted; converting it too would overstate a fund holder's position by the wrapper wedge. Both display surfaces take the finished figure off the served row (PositionRow.quantity, V2ActivityRow.amount); neither carries a unit qualifier, so no client can reintroduce a label or a second conversion.
M34 — Pendle PT: composed over the payout asset, per-lot accrual at read time, factor on both lines
Supersedes M6, M6a, M11, M12 and M13, which are one rule and were five. The five said, in five places, what a principal token is worth; two of them described a curve nothing published, one carried a "known gap" that is now closed, and the market-vs-redemption story had to be assembled by a reader out of all of them. What follows is the whole of it.
The instrument
A Pendle principal token is a zero-coupon claim on its PAYOUT ASSET (pendle_markets.underlying_address — the unit the PT-to-asset rate is quoted in, which is NOT the yield token the PT is named after). It is a COMPOSED asset over that payout asset and never a quoted one: no PT ever gets a price bar, and a PT whose payout asset this product does not track is shown under Other with the reason "Payout asset not tracked" rather than skipped (#811 A2). Which payout assets are tracked is a decision someone makes on what people actually hold; listing a market tracks nothing automatically.
The redemption index factor f, which both lines carry
A PT does not always settle for one unit of its payout asset. Pendle's yield token carries a running maximum index, YT._pyIndexCurrent() = max(SY.exchangeRate(), pyIndexStored), which never falls, and a redemption divides by it (_calcSyRedeemableFromPY pays amountPY / index SY shares). So one PT settles for
f = min(1, SY.exchangeRate() / YT.pyIndexStored())units of its payout asset: exactly 1 while the yield-bearing asset behind it sits at its peak, less than 1 once that asset is written down. The YT holder collected the interest on the way up and gives nothing back on the way down; the whole fall lands on the PT. Post-expiry the same formula holds (postExpiry.firstPYIndex only routes the post-expiry excess to the treasury).
Worked example. 100 PT-sUSDe, payout asset USDe, sUSDe rate 1.20 at mint:
| path | sUSDe rate | index | PT redeems for | YT keeps |
|---|---|---|---|---|
| normal | 1.20 → 1.26 | 1.26 | 79.4 sUSDe = 100 USDe | 5 USDe interest |
| write-down | 1.20 → 1.26 → 1.10 | 1.26 | 79.4 sUSDe = 87.3 USDe | the same 5 USDe |
| recovery | 1.10 → 1.26 | 1.26 | 79.4 sUSDe = 100 USDe | unchanged |
f IS APPLIED EXACTLY ONCE PER LINE, and Pendle's own oracle applies it before maturity.PendlePYLpOracle.getPtToAssetRate — the only market-rate source in this codebase — scales its raw rate by min(1, syIndex/pyIndex) inside its own bytecode. So every pre-maturity rate the pipeline reads already carries the write-down, and scaling it again books the loss twice. Verified wei-exact on 2026-09-02 at block 25,891,852 (https://eth.drpc.org), on matured markets where the raw rate is PMath.ONE by construction so the oracle returns f alone: market 0x8cef2919… returned 459761945664711512, which is floor(1e18 × 468348 / 1018675) exactly, and 0x91bc8689… returned 992518295290822691 = floor(1e18 × 1007150 / 1014742).
Two properties worth stating plainly:
- Use the ratio, never either leg.
SY.exchangeRate()is not reliably 1e18-scaled: at block 25,891,852 the PT-apyUSD SY returns1421268969011848084while the jrUSDat SY returns468348and the yUSD SY998453.fsurvives that only becausepyIndexStoredis assigned fromexchangeRate(), so the two are on the same scale by construction and it cancels. - The error is one-sided.
pyIndexStoredlags the true peak until the next on-chain interaction touches the YT, so between a peak and that interaction the denominator is too small andfis too high. A write-down can be slightly understated, never overstated.
f is recorded as market data on every pendle_market_state row (redemption_index_factor, migration 101) together with its denominator, and read back through ptFactorSeries / factorAt, which joins the latest observation at or before a moment and refuses past a staleness bound sized by the writer's own cadence (18h for the 6h cron, 48h for the daily backfill). Unbounded, that join would keep answering "unimpaired" with total confidence for a market that may have been written down the day after the writer went quiet. Past the bound the answer is null, and null is withheld (M9), never coerced to 1.
THE STORED SERIES STOPS AT MATURITY, AND A SECOND SOURCE TAKES OVER. The 6h refresher visits status = 'active' markets only, so a matured market's stored history ends on its maturity date and a join into that gap would answer nothing forever. At and after maturity f therefore comes from the leg's own spine row: the snapshot writer marks a matured PT at f and stores qty_underlying = qty × f, so f = qty_underlying / qty is exact at that row's own block. Those readings are kept as a small per-leg series and joined at-or-before under the same 18h bound — the snapshot grid is the 6h cron's cadence, so it earns the same allowance and no more — and a movement further than that past the last reading is withheld rather than priced off a carried factor. A value derived above par is refused outright (on chain f = min(1, …) and cannot exceed 1, so a ratio above it is evidence that the reading and the ledger disagree about the quantity, not evidence of a factor).
The MARKET line
qty × ptRate(block) × the payout asset's book-unit MARKET price, where
ptRate(block) = getPtToAssetRate(market, 900) before maturity (already carries f)
= f(block) at or after maturityA failed read leaves the mark NULL (M9), never par. Booking par is the phantom-yield bug FWS1 removed: a 100-PT buy at rate 0.95 would otherwise book +100 of payout asset — −5 of instant phantom negative yield that then accretes back — instead of +95.
ptRate is ONE function, pendlePtRateAt in src/lib/portfolio/rate-getters.ts (getter kind pendle_pt), and every path that puts a number on a principal token calls it — the position spine on a bare PT and on a PT posted as collateral, the flow ledger on every movement, and the live tip over its own per-tick batch. It used to be written out separately in each of those, over two copies of the same oracle call, which is how a level and the receipt that closes it can come to disagree about a write-down: when the par branch below was removed it was corrected in two of the three places and the third went on marking matured PTs at par. A boundary test now asserts that no other module reads Pendle's oracle or the redemption-index pair for a valuation at all.
At or after maturity the leg is marked at f, not at par. The rule this replaced read "post-maturity the TWAP oracle may revert, so the leg is valued at par". Both halves were wrong, measured 2026-09-02: getPtToAssetRateRaw returns PMath.ONE post-expiry without touching the observation buffer, so the oracle cannot revert there for cardinality reasons and in fact answered on every matured market probed; and a matured PT redeems one-for-one only while the asset behind it is unimpaired. Two of the 35 Pendle markets a tracked wallet has referenced sat at f = 0.4597 and f = 0.9925 on that date, both matured — par overstated the first by 2.17×. The mark is continuous across maturity, with no phantom step booked at the maturity instant.
The redemption flow takes the same rate, and that is a rule rather than a preference. The burn (Transfer to 0x0, classified withdraw) is by definition at or after maturity, so it sits in exactly this regime, and the invariant is M26's: a PT leg and the flow that moves it must be priced by one method, or an internal conversion stops netting to zero. Marking the level at f while booking the burn at par would show the impairment for as long as the position is held and hand it back as unearned gain the day it closes — on a 400-PT position at f = 0.4598 that is a level of $183.90 against a burn of $400 and +$216.10 of attributed gain.
The ACCRUAL line — per purchase lot, derived at READ time (#811 C)
value_redemption is NULL on every stored PT row, by design and permanently: the value depends on the yield the holder locked in at their own fills, so the receipt being written is one of the inputs to the answer. The line is derived at read time in the engine-input assembly (src/lib/portfolio/v2/pt-accrual.ts), the same seam the entered basis, the Morpho impairment record and the #802 mark-gap bridge act at. Nothing is written to the spine or the flow ledger.
Lots. Walking the leg's flow rows in ledger order, an acquisition of q PT opens a lot at fill price p payout units per PT with τ years to maturity, and locks
y = (p / f)^(−1/τ) − 1— the impairment-adjusted fill, which is what stops an already-written-down market being written down twice: a PT bought at 0.437 on a market whose factor is already 0.46 was bought at 0.95 of what it will actually pay, so its locked yield is that of a 0.95 fill and the level right after the buy is what was paid. A disposal reduces every open lot pro rata; lot yields never change, so a partial sell leaves the blend exactly where it was.
The factor a lot is struck against is read at the fill's OWN block. The flow valuation reads f at every PT receipt's block and stamps it on the row (meta.ptFactor, with meta.ptRate, the PT's own rate there, and meta.ptMarkValue, the receipt valued at that rate), and the lot book prefers it to the stored series. The series is a reading at some other moment joined under a staleness bound, and a fill that fell between two readings further apart than the bound opened an unvalued lot, and blanked the position's rate, for as long as the lot stayed open. It remains the source for a row derived before the stamp existed, and for every READING (the level, below).
A holding older than the history window opens on its REAL purchases where they are on file. A leg whose first evidence is a READING (the holding predates the flow window) used to open one synthetic lot from that row's own implied rate, qty_underlying / qty, as if bought the day tracking started. The Pendle router stream is stored whole from 2025-05-21, so at the end of each wallet build the wallet's own router fills for such a leg (attributed on receiver) are decoded, valued by the same resolver as an in-window receipt, stamped the same way, and stored (portfolio_pt_prewindow_fills, migration 113) — only when their net quantity equals the opening reading's exactly, in raw units, with no negative running balance, and every acquisition carries a fill and its factor. The lot book then opens on those lots, each at its own fill and tenor; a pre-window sale reduces them pro rata before tracking starts. They feed the lot book alone: no flow value, no withhold, nothing the engine or the chart reads. Anything short of an exact explanation (a transfer in, a purchase through a contract other than the router, a fill no price could value) stores nothing and the synthetic lot stays, flagged as such.
Level.
value_redemption(t) = u_red(t) × f(t) × Σ_i q_i(t) × (1 + y_i)^(−τ(t))with τ(t) = max(0, maturity − t) in 365-day years (the convention the whole app annualises on). At or after maturity every discount factor is exactly 1 and the level collapses to qty_underlying × u_red, so the two lines then differ by the payout asset's own market-vs-redemption wedge and by nothing else.
Flows. Every flow is valued as the change it makes to the level, which is one rule where two would have to be kept in step: an acquisition adds q × f × (1+y)^(−τ), which by construction of y is exactly q × p — what was paid — and a disposal removes q × the leg's accrual value per PT. Netting to zero on a buy, a partial sell, a wallet-to-venue move and a burn at maturity is therefore structural rather than a property two formulas happen to share.
An internal move carries its lots. A PT sent from the wallet to a lending venue as collateral is not a sale and a purchase. An acquisition SETTLED, in the same transaction, by a disposal of the same PT on another leg of the same wallet takes its fill price from the source leg's own accrual value per PT at that block, so the move books nothing and the accrual book value does not step at a moment when nothing economic happened.
A balance adjustment sets the lot book to the reading's holding (ledger-first R5b). Where the reading audit corrects a PT leg (a reading found more or fewer PT than the recorded movements explain), the lot book is brought to the quantity that reading states, by the difference from what the book held just before, rather than moved by the correction's own recorded size: the two agree wherever the book and the record already agreed, and where they did not, the book still lands on what was read. More PT opens one lot, listed as a balance adjustment and never as a purchase, struck at that reading's own price (its qty_underlying / qty), exactly as a top-up at that reading would be; where that reading is no longer stored (a live reading a later one replaced) and the correction was kept where it stands, the copy of the reading the correction carries gives the same price, and a stored reading at that block wins over the copy (PR #959 review round 2, SF-A: priced from stored readings alone, such a lot had no price and the leg's accrual value was withheld for its whole life). Fewer PT reduces every open lot pro rata, valued at the leg's accrual value per PT, as any disposal is. Either way the flow value is the change the correction made to the level, so the accrual line nets it at the reading that found it. A correction on collateral a venue holds index-scaled (Aave, SparkLend) states no PT quantity this book can read, so the book cannot be set to the corrected holding and would go on valuing the holding from before the correction. It is not resized on a guess: from that reading on the leg's accrual value is withheld, the level as well as the correction's flow (not measured, pt-quantity-unresolved), and so is every flow the book would value, until the ledger states the leg empty (an exit in full): then the holding is known to be nothing, the book is emptied, and a later re-entry is measured again from its own fill. A correction that finds such a leg empty states its quantity on every venue (none) and is booked as a disposal like any other.
The two derivations this rule had to choose, and why.
The fill price is what was paid, in PAYOUT units, by one rule for every stamped acquisition. The stamps give the payout coin's own bar at the fill,
px = ptMarkValue / (q × ptRate), andp = (value_market / px) / q: what was paid, valued at the bar of whatever it was paid with, converted into the payout units it bought at the same anchor. For a fill paid in the payout coin this is the coin count exactly; for a fill paid in USDT against a USDC payout it is the USDT at the two coins' ratio; for a mint, or PT received from outside, which are valued AT the PT's own mark (value_market = ptMarkValue), it isp = ptRateexactly, the PT's own price in coins. The flat-$1 conversion it replaces (value_market / u_red) read a fill paid in USDC at a 0.9996 bar 3.6bp cheaper than it was (0.16 points of yield on a 0.22-year PT), one paid in USDT at 0.9993 about 23bp of yield high on0xf629…c4(10.71% served, 10.48% exact), and a mint as bought off the market's own price by the coin's deviation from $1. A row derived before the stamps keeps the older rule: the coin count whereamount_underlyingstates one (a bare Pendle leg's buy paid in the payout coin; deliberately NULL on a mint and a redemption, and PT smallest units on a PT-collateral leg), andvalue_market / (u_red × q)otherwise.The unit converter is
u_red, on every row and in both directions. The curve is stated in payout units and converted to the leg's book once, at the payout asset's own REDEMPTION value. The accrual line has no business quoting a market price, and using the market converter for the fill while using the redemption one for the level would put an acquisition's accrual value a tape deviation away from its market value.u_redis 1 for an identity asset of the book and is otherwise NOT READ, which is a different thing from not existing.redemptionRateKindwould answerrate-sourcefor a yield-bearing payout asset, and a share rate for it may very well be stored — but resolving one is a database read per (token, block), and the accrual line needs a converter at every flow block and every grid point, which on a page served on every load is a query storm. So the read path answersidentity → 1and withholds everything else, and the withhold reasonpt-payout-rate-unavailableshould be read as this read does not resolve that rate, not as no rate exists: the remedy is a batched per-asset series (the same shape the factor series already has), never a coverage decision. No payout asset needs it today — all three tracked ones are par claims.Note which assets that does NOT withhold.
redemptionRateKindanswersidentityfor stETH and eETH (they are inPAR_ACCOUNTING_ASSETS: one stETH TOKEN redeems for one ether), so a PT paying out a rebasing base takesu_red = 1on the token basis and is valued rather than withheld. That is the right per-token answer here — a PT's payout is denominated in tokens — and it is the same share-versus-token duality R5 draws elsewhere: a wallet leg counted in SHARES asks a different question and is answered byshareRedemptionRate, not by this.
What is withheld, and what is never assumed. A null fill, a null factor, an unresolvable quantity or an unresolvable payout rate leaves the lot unvalued, and one unvalued open lot withholds the leg's accrual value on every later row — never par, never silently. The engine's own W2 unpriced-input declines the leg-interval; the derivation names the reason beside it (pt-lot-unvalued, pt-factor-unavailable, pt-payout-rate-unavailable, pt-quantity-unresolved).
The PT row, opened: the open position, as a fixed-income desk carries it
A directly held PT's row opens onto the figures below (PositionRow.ptDetail, src/lib/portfolio/pt-detail.ts, pure over the lot book and the row's own reading). Money is in the row's book; τ is the tenor left at the row's reading.
The open position since purchase, the amortized-cost view (Fred's ruling, 2026-09-23): a point-in-time statement of the PT still held, at the leg's latest reading, in the PAYOUT COIN's own units carried into the book at its par claim
u_red(the units the lots are struck in; the row's Value column keeps its own market figure, and the panel's caption says which terms these are). Three levels of the same holding, over the open lotsi:cost = u_red × Σ q_i × p_i what the open lots cost (the striking rule above) BV = u_red × f × Σ q_i × (1 + y_i)^(−τ) the holding at the rates it locked in MV = u_red × q × P, P = qty_underlying / q the holding at the reading's own price carry = BV − cost mark to market = MV − BV total = MV − cost = carry + mark to marketfis the lot book's factor at the reading. A lot'sBVstarts at what it cost (that is how itsy_iis struck) and ends at its face, so carry runs from 0 at purchase toq × f × u_red − costat maturity: what the position has earned at its locked-in rate (and it carries a write-down off, as the accrual line does).BVandMVboth end at the face, so the mark to market fades to zero by maturity whatever the market does in between: it is how far the market value sits from the locked-in value. At or after maturityτ = 0: the mark to market is 0 and the whole return is carry.The mark to market splits where every open lot states the market's own yield at its purchase,
y_m,i(from the stampedptRateandptFactor; a stand-in lot's is its own locked yield, so it contributes nothing), withy_now = (P / f)^(−1/τ) − 1:your price vs the market then = u_red × f × Σ q_i × [(1 + y_m,i)^(−τ) − (1 + y_i)^(−τ)] rate change since you bought = u_red × f × Σ q_i × [(1 + y_now)^(−τ) − (1 + y_m,i)^(−τ)]At the purchase the first is exactly what was paid above (negative) or below the market's own price at that block,
q × (mark − paid); the second is the market's yield moving since. Both fade to zero by maturity; the second is served as the residualmark to market − first, which is the formula exactly because the lots hold the reading's quantity. Otherwise the mark to market is stated unsplit. A mint or PT received from outside is struck at the PT's own price (above), so it paid nothing against the market.Whole or not at all. No price at the reading (before maturity), no factor, no par claim for the payout coin, an unvalued open lot, or lots whose open quantity differs from the reading's PT count (a trade the reading does not include yet) nulls every figure of the statement at once: a dash on every line, never a zero, never a partial split. What it is not: it is not the row's total return over the chart's window, and it does not restate what PT already sold made. It is a statement of the lots and the latest reading, so a gap in the history between them (a mark the chart could not read, a factor the series lacked) leaves it exactly where it was.
On
0x3bd8…b5at the 2026-09-23 06:00 reading on staging (two USDC fills, 87,393.66 PT) it reads cost 85,460.41 USDC, carry +$79.07, mark to market −$73.96 (rate change since you bought −$9.93, your price vs the market then −$64.03), total +$5.11.Locked-in rate: the row's own fixed APY (above). Market rate now: the yield implied by the reading's OWN price, the same one its value uses, on the lot's own formula:
y_now = (p_now / f_now)^(−1/τ) − 1,p_now = qty_underlying / qty. Null at or after maturity. The difference is stated in basis points.Profit if held to maturity:
u_red × Σ_lots q_i × (f − p_i)— the face of the open lots at the payout's par claim, less what they cost: the statement's own cost basis, so it is the carry the statement reaches at maturity at today's factor. Where an open lot is the synthetic one, its cost is its value on the day tracking started and both the statement and this line say "since tracking started".Rate sensitivity: a full repricing one point higher,
MV × (((1 + y_now + 0.01) / (1 + y_now))^(−τ) − 1), rather than theyears × 1%duration shortcut the carry screener's depth card states (the two differ by1/(1 + y), about 10% at a 10% rate). Zero at maturity.The purchases: every open lot with its fill date, remaining quantity, price paid per PT in the payout asset, its locked yield and the market's own yield at that block (from the stamped rate and factor), and where it came from (bought, minted, received, bought or minted before tracking started, held when tracking started). A lot carried across an internal move keeps its original fill's facts.
Cost to sell now: what the whole position would receive if sold into its market this minute, against what it is worth this minute. ONE sell quote for the account's full PT balance (PT -> payout coin, Pendle's hosted SDK, the carries calculator's own integration) against a reference struck the way the row is priced:
reference = PT count × ptRate(current block) (the one PT rate method, read now) cost = (reference − proceeds) × converter, converter = value_market / qty_underlyingboth sides in payout coins carried into the book by the row's own converter, so the payout coin's own price moves them alike and the figure is the sale's fee and price impact. The reference is "now" or nothing: if the live rate cannot be read, the row's own reading stands in only when it is under ten minutes old, because an older price would report the market's move since the reading as a trading cost. SIGNED: a quote above the current price (a time-weighted oracle lagging a market that moved up) is a negative cost, stated as one. Fetched only when the row is opened, never on a past day or for a matured PT (redeemed, not sold); measured on staging 2026-09-23 at 0.071–0.074% for holdings of $16k–$200k in PT-reUSD-10DEC2026.
Market depth, beneath it as context: the market's latest pool liquidity in dollars and the position's share of it. A live figure, omitted on a past day.
One PT held in several wallets opens on one statement: each money figure is the sum of the wallets' own (one unknown term makes the sum unknown, and one wallet whose statement is withheld withholds the combined one whole, as one whose mark to market does not split leaves the combined one unsplit), so the combined statement foots as each wallet's does; rates are by quantity.
What the surfaces show
- A FIXED APY on the position row: the quantity-weighted locked yield over the open lots, in the column that reads "advertised rate" and has always dashed for a PT because no venue quotes one. Exact, not a hand composition, and it survives an as-of read.
- An
impaired: { factor }flag whenfis below 1. It is a LABEL: both marks already carryf, so a surface that netted it off a value would book the write-down twice. Served on the API; the page renders it in a follow-up. - A dated Activity line when
fsteps down while a tracked leg holds the market — beside the entries, never inside them, because an impairment has no transaction, no block of the holder's own and no amount that moved. A RISE is not an event: the index is a running maximum, so a rise is the recovery of a fall already reported. Narrowed to the leg's own held span (a fall before the holder bought in was already in the price they paid, and one after they closed belongs to whoever holds it now) and cut at the as-of day, so a past-day statement never carries a line from its own future. Served on the API; the page renders it in a follow-up. - The reason a value is a dash, per line, on the row (
markWithheld): which of the four causes above withheld the accrual line, ormember-mark-missingwhere the row's own mark was fine and a financed sibling's was not. Served on the API; a financed position that could not be priced says so on its row, with a route to support. - The annualised figure is withheld over the interval carrying the step, through the existing
impairedIntervalshook. A fall is not a run rate. That is the ONLY figure the dated event changes.
A financed group publishes all of itself or none of it (#794 second half, #845, #856)
In a financed group — a carry_trade or any supply+debt loop — a line that cannot be stated for every leg of the group is withheld for the whole group. A financed position is one economic holding whose book value is collateral − debt, and any part of it is a different holding: the borrow alone is negative and reads as a loss the holder never took, the collateral alone is an equity with no liability behind it and reads as wealth they do not have, and a position short one leg of several invites a net that is wrong by exactly the leg that is missing. None of them is distinguishable from a complete figure once it is on the page, so none is published. The position's row says so, with a route to support, and the position is left out of book value, return and accruals on that line — at the readings where it is dark, not for ever having been. A row serves no yield on a line it is withheld on at the reading the row is stated for (a dash, never the engine's declined zero); a book's return, accrual and value lines are gaps at every reading where any charted financed position in it is withheld on that line, and stated everywhere else, because a one-reading gap the engine bridged is one point of the line and not the line. The newest reading decides the headline: withheld there, the book value is a dash and the cumulative figure is flagged as unstated beside it.
Four conditions, all required:
- Something could not be valued. A live leg with no mark on the line; a leg with a mark is never a reason.
- The group holds both sides at that point. A loan not yet drawn, or already repaid, leaves an ordinary holding, and it keeps its own marks — the tip of a wallet that has just closed a loan is exactly that. A leg with no reading at all is a gap in the readings, which the coverage rules report on their own, not a gap in the valuation, and a row of zero is not a side.
- Something could be valued. A group with no mark at all on a line publishes nothing already, and there is nothing to withhold.
- The leg has a claim on that line. On the redemption line only, and since 2026-09-18: an asset that settles in no currency book (bitcoin and its wrapped forms, gold, a governance token) is valued at market in dollars and carries no redemption mark at any reading, by construction. Its blank is the absence of a claim rather than a missing mark, so it is not a reason to withhold and it is not itself withheld. Cross-currency borrowings hold such legs inside the position, so without this condition every one of them would withhold its own redemption line for as long as it lived, and would take the redemption marks of the priced collateral and the priced borrow beside it — and with those, the currency book's own accrual and value lines — down with it from the moment the borrow was drawn. The group's redemption figure is then over the legs that have a redemption claim and is short by the others; nothing publishes it, because a cross-currency borrowing is listed in the All view alone, which states value at market in dollars, and its row states no rate and no yield. The market line is unaffected: every live leg is a participant there, an unpriceable one included.
The cost is deliberate, and it was chosen with the alternative in view. On Aave and SparkLend the group is the whole account, so one reserve that cannot be priced withholds every supply and borrow on that account on that line and drops the account from book value and return. The narrower rule — withhold only when a whole side is unpriced, so an account with one unreadable reserve keeps its other correctly priced legs — publishes a partial figure a reader will net, and the net they reach is wrong by the leg that is missing with nothing on the page saying so. A withheld number is unknown rather than zero (M9).
The total follows the same statement for the positions this rule governs, the charted ones. Where any leg of such a position could not be priced, the headline value is withheld outright rather than stated short, and the reason is printed beside the dash with the same route to support. The same dash appears for a financed position whose legs carry marks but whose dollar quote is missing at that reading (an ether leg with no mirror bar): the rule itself never sees that gap, but a total short a leg of a financed position misstates it either way. A Not covered position is not governed by it, financed or not: since the 2026-09-18 taxonomy it sits outside the All view's total and its value history in both directions, its borrowing included, and the caption beside the figure counts it (M22). A standalone holding that could not be priced is left out and said so, because the rest of the total is real. A supplied pool pair short one token's mark falls between the two: the pair is one position, so its row states no value at all rather than what its other token is worth and the denomination card's tracked value is withheld with it, while the All view leaves the pair out of the headline and says so rather than withholding a whole figure over one unpriced supply.
Scoped to financed groups: on a standalone leg a dark mark costs that leg's own line and there is no neighbour's figure to protect. Per line, because a mark gap is per line — the market line can be complete while the accrual line is dark.
The market universe
A PT never silently leaves the universe while any tracked wallet still holds it. loadPendleMarkets is active OR within the 45-day matured grace OR held-by-anyone, evaluated as four correlated EXISTS probes (one per shape a PT address takes, with the venue that carries it): (1) a DIRECT pendle snapshot leg (venue='pendle', key pendle:pt:<pt>, field 3; the reader books accounting_asset = the payout asset, so the PT is only in the key), (2) a PT as ANY venue's accounting_asset (the flagship aave/spark PT-collateral loop, plus any Morpho/Fluid PT-collateral or PT-asset 4626), (3) a PT as ANY venue's flow asset, and (4) a DIRECT pendle flow leg. Arms 2 and 3 carry NO venue filter on purpose: a PT in any venue's accounting slot must be caught, or its leg silently drops. Migration 059 adds a dedicated index per arm (partial WHERE venue='pendle' functional indexes on lower(split_part(...)) for arms 1/4; full (chain_id, accounting_asset) / (chain_id, asset) for arms 2/3), and with those indexes the cost is O(#pendle_markets) rather than O(all-users history).
A PT held on a lending venue as collateral (its reserve underlying, or the market's collateral token, IS the PT address) is valued and charted the same way: the known PT is promoted into its payout asset's book (
buckets.ts), MARKET = the PT amount ×pt_to_asset_rateat the block × the payout asset's book price, and REDEMPTION is the same read-time per-lot curve, on this venue exactly as on a bare Pendle leg — a PT posted as collateral is marked exactly like the same PT held bare, which is what makes attribution run on the instrument's own fixed-rate series rather than treating the collateral as par. So the flagship PT-loop carry (PT-srUSDe collateral + stable debt e-mode) charts as an included same-book carry instead of dropping to "Outside". An unknown PT (not inpendle_markets) stays EXCLUDED, honestly.Which venues can be in that shape is DECLARED (
PT_COLLATERAL_VENUES, inpt-collateral-venues.tsand re-exported bysnapshot.ts), together with how each states the collateral's quantity — because the two differ, and the difference is the whole of the arithmetic:
Venue The leg's qty_rawThe PT amount Aave, SparkLend the aToken's scaled balance, with the reserve's RAY index on the row rayMul-descaled by that index; a NULL index IS an M9 skip (nothing to descale with) Morpho Blue the raw collateral units the market holds qty_raw ÷ 10^ptDecimals;index_rawis NULL by construction (Morpho pays no interest on collateral) and that is NOT an M9 skip
ptDecimalsispendle_markets.pt_decimalson every venue, never the lending venue's own copy (morpho_market_registry.collateral_decimals, an Aave reserve'sdecimals): the PT registry owns the token, the venue's copy decorates its market row. They agree on all 73 Morpho markets whose collateral is a known PT (staging, 2026-09-05), and a disagreement is a registry-sync defect to fix there rather than a choice for this path.The declaration also names which leg of each venue is stated in PT units, and on a
rawvenue that half is load-bearing: Morpho's supply and debt legs are SHARES of the LOAN token, so a market whose loan token is a PT would otherwise divide shares by10^ptDecimalsand publish a number about 1e6 out with no null to catch it. Such a leg is skipped under M9 instead, as it was before this path existed. Aave and SparkLend name no leg: a reserve's accounting asset is its underlying on both sides and each side descales by its own index.Morpho Blue joined in 2026-09 (issue #754). Before it, a Morpho PT loop's collateral leg fell past a two-venue test into the generic wrapper path, where a principal token has no share rate, and was skipped under M9 with nothing naming it — so the market published its debt with no collateral beside it and landed under "Debt without matched collateral" (M22's
cross-book). The Aave/SparkLend arithmetic is unchanged.
M7 — the wedge (mark divergence)
When the two marks diverge on any held asset, the summary reports it (SummaryResponse.wedge, PositionRow.wedge / diverged). Served, not rendered: no component reads either field today, so this is an API-level signal and a monitoring hook rather than something on screen. What a reader sees of the same fact is the per-position basis ledger (M18: basis at entry, current basis, dislocation since entry) on an expanded carry. The trigger (assetWedge, per held asset; the summary's flag is set when ANY held asset diverges):
|MARKET − REDEMPTION| / REDEMPTION > 0.5% (stables, USD book)
> 1.0% (ETH wrappers)Thresholds live in one place, pnl.ts (WEDGE_THRESHOLD_STABLE, WEDGE_THRESHOLD_WRAPPER); a non-positive redemption denominator never flags.
M8 — the 30-day annualization gate and other exclusions
An annualized figure (realized APY) is suppressed below 30 days of observation (annualizeTwr returns null when the observed span is under MIN_ANNUALIZE_SECONDS). At or over 30 days the TWR compounds to a year by the actual observed span (the annualizeRatio convention; a total wipeout cannot be annualized as a ratio and returns null). The gate applies only where a figure is compared against an advertised annual rate: the positions table's per-leg realized APY (legPerformance → annualizeTwr), its earned-vs-advertised column. That column is two rates, not one, and under the rebuilt engine both are the same reduction over the two series the law already walked: the redemption column annualizes the accrual line's linked return, the market column annualizes the total-return line's. They agree on a plain index leg as a property of the data; where a position's price wanders from its redemption value — a PT, an LST wrapper — they do not, and one figure published under both labels would assert that the wedge between them is zero. The gate has nothing to say about the portfolio level, because the portfolio level publishes no return percentage at all: a per-book realized return and a per-book TWR were served on the summary until both were removed in September 2026 (M8b below). Also excluded in v1: MWR/IRR, rewards and points (permanent UI label "Base yield only. Rewards and points are not included."), gas costs, blended cross-book totals, positions held via proxies/Safes not signed in directly, multi-wallet accounts, and MWR/IRR.
M8b — the portfolio level states an absolute return, never a percentage of it
A book's headline is three AMOUNTS in the book's own unit (cumulative yield, book value, realized losses) and no ratio of them. The account level reports the same amounts summed and, likewise, no ratio.
Two percentages were published here until September 2026 and both are gone: a realized return (totalYield / baseCapital, a simple holding-period return on the capital first deployed) and a TWR (the flow-neutral curve.twr, surfaced as a percentage). Each divided by the first capital the book ever held, which is a denominator the reader never chose and the engine cannot vouch for. On one tracked wallet the first positive reading was $0.009861 of dust, and 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. A minimum-capital floor would have replaced an absurd answer with an arbitrary one, so the metrics were removed instead. Nothing on the page rendered either, so no reader loses a number.
What is NOT affected, because a rate on one position answers a question a percentage can answer:
| Kept | What it is | Where |
|---|---|---|
| Per-leg realized APY | the annualized rate on ONE position, in both marks, gated at 30 days (M8) | positions table, earned-vs-advertised column |
| Category realized APY | the same annualized rate over a category's roll-up within a book | CategoryMarkHeadline.realizedApy |
| Net APY / daily income | the headline forward rate on the current book, and the income it implies | the dashboard rail |
| Cumulative yield, total return | the two cumulative AMOUNTS, in the book's own unit | the chart lines, and the summary headline |
The TWR itself is not gone from the engine, only from the wire: it is still what the positions table's annualized column is built from, interval by interval (M3). What was removed is the act of publishing a whole book's linked return as a single percentage.
M9 — data honesty
Failed on-chain reads are skipped, never written as zero; empty (0x) returndata from an eth_call counts as a failed read (a codeless address "succeeds" with 0x). A written snapshot row always carries a real qty_raw; the valuation and derived columns are the nullable ones. Gaps stay gaps, never interpolated, and history never extends below a wallet's own history floor (a rolling 30 days before the wallet was added, and never below 2026-01-01, where the portfolio view's supported window starts; the raw event store is ingested deeper, to 2025-05-21, but nothing below the derivation floor is ever derived or served; PT realized yield only from rpc-basis rows). What a gap may be DRAWN as is a per-chart decision: the signed-in portfolio chart holds the last reading flat across an interior one (portfolio), which draws only values that were really observed. Interpolating between two readings, or drawing a value for a period the series reports nothing for on either side of it, stays forbidden everywhere.
A withhold crosses the wire as null, never as 0. Inside the rebuilt engine a withheld leg-interval books a magnitude of 0 and files its withhold row beside it, which is the right internal representation: the curve is a sum, and the withhold ledger carries the reason. On the served response there is no withhold ledger next to the point, so a 0 there is read as a MEASURED zero — a flat "$0 earned" line drawn over real book values, which is a stronger and more confident claim than the partial number it replaced. The reader therefore translates at the boundary: an interval, a leg or (for W7 uncertified-scope) a whole wallet the engine declined to book publishes null on both return channels and a dash in every cell derived from them. That is every attributed figure on the wire, not the chart's two lines alone: the per-position cumulative yield on both marks (the holdings tables' Yield earned column and the carry panel's Total net yield earned) is null under the same rule, because a 0 there is the same measured claim standing beside a real balance — and on a surface with no building-chart gate over it. Every total built from those figures inherits the unknown rather than dropping the term it cannot read: a fused carry's net, a like-for-like row merged across two wallets, and any wallet-level total over them. Book values are unaffected — they are readings from the position spine, and a coverage withhold is a statement about what can be ATTRIBUTED between two readings, not about the readings. The aggregate then needs no rule of its own: mergeDailyPoints already nulls any day a spanning wallet reports null for, so one unproven wallet makes the account's total a gap instead of the sum of the wallets that could report.
Unexplained birth (the newborn value/pt leg). A value/pt leg born in an interval is attributed from v0 = 0, so its principal cancels against its birth flow. If the ledger carries no flow for that interval, the cancellation never happens and the entire principal books as yield (a $100k deposit reads as "$100k earned"). Value cannot appear from yield alone — a leg is born by a flow, or it is an opening balance — so legIntervalYield treats a birth the ledger cannot explain as an opening balance (0 yield), exactly as the index path already does unconditionally. The guard keys on birth and a zero net flow, so it never suppresses real accrual: when the birth flow is present (the normal case) the interval's genuine yield (value in excess of the flow) is still attributed, and an already-established leg keeps booking its flow-free appreciation.
This is reachable because the JIT mini-scan's flow persist is best-effort: it takes the shared writer lock with a TRY and SKIPS when a backfill or the 6h cron holds it (write-lock.ts — the lock is one GLOBAL key, so any wallet's backfill or the cron's own write can cause the skip). A position opened since the last snapshot can therefore be live-merged before its deposit lands in the ledger. The next scan re-detects the flow and the leg attributes normally; the guard is what keeps the intervening page load from showing an invented number. See issue for the structural fix (per-wallet lock keys, which requires the 6h cron to commit per wallet).
M10 — the read model + APIs + UI (WS6)
The user-facing surface is the authenticated routes under src/app/api/portfolio/* (all force-dynamic), fed by one server module (src/lib/portfolio/ledger-v2-api.ts) that reads the stored rows and answers from engine v2 (src/lib/portfolio/v2/). Every wallet-scoped route resolves its wallet server-side from the session cookie; /prices is account-independent and takes no wallet at all. 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 — the routes never read an address from the request). The read model is stale-while-revalidate: the GETs serve stored rows only, the persisted live tip among them (first paint never waits on RPC); POST /api/portfolio/refresh triggers the live read after paint, which stores its reading once that wallet's ledger merge has committed, and the client re-fetches when it lands (see Portfolio). None of the numbers change with the split — jit only says whether the newest reading is a live tip or a 6h checkpoint, and the methodology is the same either way.
- Re-composing the index is OFF the served read path. The snapshot row stores the bare venue index (
index_raw) plus the two already-book-valued marks, and the accounting-asset→book redemption rate can be recovered exactly from the persisted values (rate = value_redemption / qty_underlying) and re-fused onto the bare index through the sanctionedcomposeIndex(buildIndexLeg,valuation.ts). The served engine does not do that: it attributes from the stored values directly, and recovers the rate AFTER the booking as a cross-check no booked number may depend on (recoveredRates,v2/attribution.ts). The composition is still live on the WRITE side, wherever marks are being computed rather than read: the JIT refresh and the 6h tick both carry a wallet's non-dirty legs forward throughrecompose.tsrather than re-reading every venue. - Read-time inclusion. M1 (
classifyLegsAtTs) is applied to the legs present at EACH snapshot ts, andcomputeViewSegments(M22) coalesces the verdicts into time spans. A since-closed position keeps the verdict of the last observation that saw it, and a restructuring moves a position from the tick it happened rather than restating its whole history. - Tracked history reaches back a rolling 30 days from when the wallet was added, and is daily before signup. The registration backfill replays from the UTC midnight 30 days before that wallet was first added, raised only by the wallet's own first activity and never opening below 2026-01-01 — the derivation floor, the start of the supported window, which clamps every term up. A wallet added before that date starts there: the months below it are out of scope rather than missing, and so is anything before a wallet's own window. A position older than the window opens at its value on the window start and its yield and return count from there, which is the same rule a pre-2026 position already follows at a further boundary. Wallets tracked before this rule shipped keep the fixed 2026-01-01 start they already have. It replays the position groups the wallet holds when it runs PLUS the groups it held at any point inside that window (group = the venue account for Aave/Spark, the market for Morpho, the NFT for Fluid, the instrument for a PT/vault — per-leg pruning would misstate historical net value), on a daily (UTC-midnight) grid plus one 6h seam point; 6h resolution begins at signup. A group closed INSIDE the window charts over its own lifespan and its exit is a position exit, not a deposit; a position closed BEFORE the window has NO history here by design; a position closed AFTER signup keeps its tracked history (closing a position prunes nothing — only a manual repair re-run re-derives the replayed set). The chart's daily reduction makes pre- and post-signup density visually identical.
summary— per-book headline in both marks (cumulative native-unit yield, current book value, realized losses: three AMOUNTS and no percentage, M8b), backfill status (syncingwhilequeued/running), and the wedge (M7). A per-book realized return and a per-book TWR were served here until September 2026; both were removed rather than floored, because each divided by the first capital the book ever held (M8b). The annualized realized APY survives in the positions table's earned-vs-advertised column (next bullet). The dual-mark block is kept populated for wire stability, and the dashboard reads the market side of it. A best-effort JIT "now" read (live.ts) STORES its reading as the wallet's off-grid live tip when it is complete, so a five-minute-old position shows from the database on the next fetch rather than from an in-memory merge;readAt/readBlockname that reading's block and its time, andjitsays whether the newest stored reading is a live tip rather than a scheduled checkpoint. A stalled RPC stores nothing, so the stored history stands and those three fields keep describing the last reading that landed.positions— per-book rows (value, native-unit yield since tracking, realized APY vs the current quoted rate) plus the categoryless groups with their reason (wire keyoutside, rendered as the Not covered band). The earned-vs-advertised quoted rate is the current advertised APY for holding that exact leg: a wrapper reserve adds the wrapper's owntoken_yield_apyon top of the venue reserve rate; an ERC-4626 vault reads its own total APY; a Fluid normal leg reads the Liquidity-Layer rate (+ wrapper), a Fluid smart leg composes the pool fee APY (earned on either leg; it reduces a debt leg's funding cost), the LL rate and the wrapper, marked APPROXIMATE (~); a reserve/market/pool not tracked shows a dash (never 0%). See "Fluid quoted rates" below for the composition and the wound-down annotation.history?book[&unit][&bucket]— the chart series, bucket-reduced onto a uniform grid (bucketReduceCurve); a bucket with no snapshot emitsnull, so the response states exactly which buckets were read and never interpolates across one (M9). The signed-in chart DRAWS an interior gap as a flat line carried forward from the last reading (a presentation decision, 2026-08-12; see the portfolio page), which changes nothing about what is served. Both lines ride one response:cumulativeYieldis TOTAL RETURN (M23) andaccrualis the accrual line (M24). The wire key was deliberately not renamed, so a client from the previous release keeps drawing a curve rather than an empty chart. Flow markers are the per-tx net external flow (a leverage loop collapses to ~0); liquidation markers are the M4 realized-loss events, and each one'slossis nullable: a penalty the pipeline withheld (M9) is served as absent rather than as 0, because a seizure that cost nothing and a seizure whose cost is unknown are different statements. A flow marker below one dollar of the served unit is omitted as display noise (an ERC-4626 flow's principal comes from shares × index, so descaling a large share count against a 6-decimal underlying leaves a few cents of residue that is not a capital move; the rows and the netting are untouched, andeventsstill serves every one).book/unit/bucketare validated strictly (an unknown value is a 400). ThemarkREQUEST param is accepted and ignored — absent,market,redemptionand a typo all serve the same two lines, so a client mid-rollout is never handed a 400 on every chart it draws. ThemarkFIELD in the RESPONSE names the basis of the POINT values (redemption: the curve is built in that mark, which is whatbookValueis stamped in and what the accrual line attributes), andmarkerMarknames the markers' own basis (market: what a capital move was worth when it happened). They were one field until 2026-08-06, reportingmarketbeside a redemption-marked book value — a client reading it to interpret the series was wrong by exactly the market-versus-redemption wedge the two-line chart exists to show.- The series starts on the ACCOUNT's anchor, for every book, so a book opened later than the account is charted from a flat lead-in rather than trimmed to its own first observation (the reasoning is in Portfolio).
trackedSinceon this response therefore equalssummary.trackedSince. Nothing is clipped off the front either: every flow and liquidation marker the wallet has is served, including an opening deposit sitting at or before the first reading, which is the one flag that explains where the curve's first value came from. bucketdefaults to1d: one point per UTC day, stamped at midnight but carrying that day's LAST observation (the 18:00 window) — an end-of-day reading, not a midnight one.6hserves the raw cron cadence, where each point is its own aligned window; since the 7-day range was removed (2026-08-10) no chart timeframe requests it, but the API keeps serving it.- The newest observation is served at its own timestamp on BOTH widths, and the bucket still open around it keeps only the reading at its start. That is what makes one instant carry one value whichever width a client asks for: before it, the 1d series stamped a part-finished day's newest reading at midnight while the 6h series served that day's earlier reading at the same second (measured on prod: $255,067.18 against $254,326.77, and 2.7% of an entire ETH-book total return on another wallet). Only the merged JIT live read carries the
liveflag (stampedLiveResult.computedAtMs, so the tooltip's clock time is when the chain was read, not when the GET arrived); a tipped cron snapshot is not labelled as read-just-now. The?wallet=allaggregate, whose merge steps a uniform grid and would step over an off-grid point, asks for the tip folded back into its bucket.
- The series starts on the ACCOUNT's anchor, for every book, so a book opened later than the account is charted from a flat lead-in rather than trimmed to its own first observation (the reasoning is in Portfolio).
events— the paged raw flow ledger, most recent first.
The UI (src/components/portfolio/*) is view pills (All / USD / ETH, mono pill groups, the CarryChart pattern), the aggregate hero, the PortfolioChart (Recharts 3) drawing both lines, the positions table, the Not covered band in the All view (the positions this product cannot value, listed with their value and their reason, outside the total and the value line), and the permanent "Base yield only … Tracked since <floor_ts>" footer. There is no price-basis switch: the dashboard renders one valuation and the chart draws both readings at once, so nothing asks the reader to choose a basis. The /portfolio page shell stays prerendered/static; all per-user data flows through the four APIs.
A day's earnings at the current rate is the one figure on this surface that is NOT a realised ratio, and it is labelled so it cannot be read as one. The hero rail's projected daily income and each wallet row's 24h accruals are the same quantity, WalletMetrics.daily in signed-in-model.ts:
daily = Σ over rated positions ( value_at_market × quotedApy / 365 )
quotedApy is the venue's ADVERTISED trailing-24h rate (see the quoted-rates note in docs/data-pipeline.md), so this is what the book earns in a day if the rates it carries hold, not what it earned in the last day. A position with no quoted rate is in neither this sum nor the blended rate reported beside it, so daily is NOT value × blended APY / 365: value counts every holding, rated or not, and the two agree only on a book where everything is rated. A book with no rated position at all reports a dash rather than a zero, on the hero rail and on every wallet row alike, because the sum is then a structural zero standing for an absence. That dash is gated on the rated COUNT, not on the blended rate: the blend additionally requires positive value, so a position that is rated and currently worth nothing has a real zero to report and no blend to report it against. Nothing here averages annualised rates, and nothing here is a published yield in the sense of the convention above: every cumulative and realised figure on the page is still an index ratio between two blocks.
The chart's series legend carries a single InfoTooltip defining BOTH lines against each other (they are only meaningful as a contrast): what each one counts, that the distance between them is the valuation effect over the drawn window, and what closing a position does to them (it keeps what the position earned and books the move between its last reading and the close; only execution below the price at that moment is uncaptured). The cumulative-figure tooltip names the account's real floor_ts anchor ("since tracking began on 15 Apr 2026") and says nothing about how that date is derived. The derivation is not tooltip material: it is day_floor(max(this wallet's own history floor, derivation floor 2026-01-01, first replayed-group activity)), where 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), and the tempting one-liner ("your last 90 days") is FALSE both because the window is thirty days and fixed at the moment the wallet was added, not ninety and rolling forward, and because the first-activity clamp binds for most wallets. A wallet whose own floor is deeper than ninety days also reaches its anchor in two passes, so the date can move earlier once; an ordinary signup reaches it in one. Naming the date answers the reader's actual question, which is what the number is measured from.
M11 — PT entry basis: RETIRED
Folded into M34.
The per-lot yield-to-maturity this rule described is now one half of M34's accrual line, where it is DERIVED rather than merely defined: each fill locks y = (p / f)^(−1/τ) − 1 at its own impairment-adjusted price, partial sells reduce every lot pro rata without moving a yield, and a holding that predates the flow window opens one synthetic lot from its opening reading. What changed beyond the location: the fill price comes from the row's own market mark rather than from amount_underlying (which is NULL on a mint, so a lot book built on it would value nothing), and the yield is struck on the impairment-adjusted fill so an already-written-down market is not written down twice. The rate is now PUBLISHED, on the position row, as the leg's fixed APY.
M12 — PT pull-to-par REDEMPTION mark: RETIRED
Folded into M34.
The pull-to-par curve this rule defined accreted to exactly par at maturity and did not carry the redemption index factor, so on an impaired PT the write-down showed up as a market-vs-redemption wedge rather than as a fall on both lines. M34 replaces it: the curve carries f(t) on the accrual line exactly as the oracle carries it on the market line, so the impairment draws once on each. The known gap this section used to record is closed by that change, and the curve it described as unpublished is published (#811 C).
value_redemption is still NULL on every stored PT row, and that half is permanent: the value depends on the holder's own fills, so the receipt being written is one of the inputs to the answer. It is derived at read time instead.
M13 — PT maturity handling: RETIRED
Folded into M34.
The market universe (active OR the 45-day matured grace OR held-by-anyone, four correlated EXISTS probes with an index each), the at-or-after-maturity mark at f rather than at par, and the rule that the redemption flow takes the same rate as the level are all stated in M34.
M14 — Fluid legs (FWS2)
A Fluid position is an NFT read from FluidVaultResolver: positionByNftId(id) returns one self-describing payload the reader decodes (readers/fluid.ts), so no vault registry is needed to VALUE a position. How a leg becomes a snapshot:
- NORMAL (non-smart) leg —
accrual='index'. The vault-level exchange price (vaultSupplyExchangePrice/vaultBorrowExchangePrice, 1e12 scale) IS the position index.qtyRaw = normalAmount x 1e12 / vaultExchangePriceis INVARIANT between operates (verified to the wei on vault 1 across 100k blocks with zero operates:108878405696930936 x 1e12 / 1088798399783 = 99998682693353195at three sampled blocks), andindex_raw = vaultExchangePrice, so descalingqtyRaw x index / 1e12 / 10^decrecovers the accrued token amount and the index RATIO between two snapshots is the realized yield.VENUE_INDEX_SCALE.fluid = 1e12. The debt leg's quantity is the net borrowing (borrow) alone and excludesdustBorrow(see The tick padding below). This is NOT thefluid_ll_apyLiquidity-Layer exchange price, which ignores the vault's supply/borrow magnifiers and serves quoted rates only. - SMART leg (T2/T4 collateral, T3/T4 debt) —
accrual='value'. A smart leg is 1e18 DEX shares; the reader emits two legs per side, one per pool token, withqty = shares x tokenPerShare / 1e18wheretokenPerShareis read fromFluidDexResolver.getDexState(dex)(words 26-29) at the block being valued (pool composition drifts with the pool price, so today's rates would misvalue a historical block).index_raw = null, so the leg accrues as a value leg (the mark's own value series carries the growth), NOTnone. The value branch is load-bearing: a par-token smart leg (a USDC/USDT DEX share) would classifynoneunder the generic rule and silently drop the DEX trading-fee yield — per-share token amounts grow with fees regardless of the token's par-ness, soaccrualForRowhas an explicit Fluid case. A smart-debt leg's shares are the netborrowtoo (1e18 shares on a smart leg). ETH appears as the0xeeeepseudo-token (the ETH par unit); it is normalised to WETH for bucketing/pricing and its decimals are hardcoded 18 (adecimals()call on it reverts).
Branch on isSmartCol/isSmartDebt from the payload, never on the exchange price: smart => exPrice == 1e12 is one-directional (vault 166's normal PST collateral also reads 1e12 because it earns no Liquidity-Layer supply interest).
The tick padding (dustBorrow), and the one place it survives. Every Fluid borrowing carries a dustBorrow amount that lands the position exactly on one of the vault's price ticks. It is not owed: the deployed vault source says so in as many words ("User's net debt = total debt - dust amount ... For user's there's no dust"), the vault charges no interest on it, no event ever states it, and a payback burns the borrow alone and leaves both fields at zero. Measured on the tracked positions it is 0.83 to 13.77 bp of gross debt, bounded by the vault's tick spacing (~15 bp).
- Every VALUE and every RETURN uses
borrow. The snapshot reader (fluidPositionReads), the derivation's state legs (fluidStateLegs), the seizure amounts, the transfer valuation and the empty-position guard all take the net borrowing, so a position's equity, its P&L and its entered basis carry no padding. A position holding only padding reads CLOSED. - HEALTH and LIQUIDATION DISTANCE keep
borrow + dustBorrow, because(borrow + dust) / supplyis the position's tick ratio and the tick is what the vault liquidates on (0.909891 = tick -63 on NFT 17548, against a net ratio of 0.909163 that is not a tick at all)./api/portfolio/riskreads the position alongside the vault's thresholds and returnstickDebtMultiple = (borrow + dust) / borrow; the LTV, the distance-to-liquidation and the health factor multiply the served debt by it, while the collateral, debt and equity cells keep the net figures. A position whose read did not land has no multiple and its risk cells quote the net ratio, which understates the tick by at most the padding.
Before this rule the debt leg was valued at borrow + dustBorrow, which charged the padding at a position's opening and released it at its close; on a levered smart pair that surfaced as a phantom exit gain of the whole padding (+$202 on one 11.8x GHO/USDC pair). Stored snapshots written under the old convention are restated by scripts/repair/remark-fluid-dust-debt.ts and the flow ledger by a ranged re-derivation; see the Fluid tick-padding re-mark.
The M14 classifier rule (rewritten 2026-09-18). A Fluid NFT is judged as one position off the base set of each side of its vault. A side has ONE base when it is a plain leg (its token's book) or a smart pair whose two tokens share a base (wstETH/ETH is ETH, USDC/USDT is USD); it has two or more when the pair spans them (USDC-ETH, WBTC-ETH — Fluid ships these), and an asset the registry books to no currency counts as a base of its own rather than as a coverage failure. The verdict:
- With debt, one currency book running through the collateral side AND the debt side is a carry in that book. Anything else is a cross-currency borrowing: listed in the All view at value, both sides and a net, no rate.
- Debt-free, the NFT is judged on its collateral side alone. Exactly one currency book charts it in that view; anything else lists it in the All view at value. Either way the category is
smart_repo_lendingfor a pool pair andrepo_lendingfor a plain leg.
The old "directional pair" verdict — a side of two bases is a bet on one asset against the other, held out of every view financed or not, reported as directional-pair — was retired on 2026-09-18 with the reason that named it. A pool holding two bases is a base mix like any other; the thing that made it unchartable was never that it was a bet, it was that a return over two settlement assets is a figure in neither, and that is exactly what the All view's value-and-no-rate treatment says. A supplied pool with nothing borrowed against it has no funding cost to strand and is now listed as what it is: Smart repo lending.
The base set is a property of the VAULT'S SIDE, decided once. What a vault's two sides are made of is fixed when it is deployed — vault 77 is a USDC-ETH vault for as long as it exists — so the set is taken once per (vault, side) over all the snapshots in scope (fluidVaultCoverage.sideBases, pnl.ts), from the accounting assets its legs resolve to books through, never from token symbols. An NFT answers to it through the sides it actually holds legs on: a vault's DEBT side spanning two bases says nothing about an NFT of that vault that never borrowed. (A side that vanishes ENTIRELY at a snapshot leaves either no group at all or a bare debt, both of which are held out anyway, so the finer grain costs nothing against the absences these verdicts exist for.) A side observed holding two different bases at any point holds them permanently and at every tick, including the ticks where only one of its two pool-token rows was read. That matters because such a tick is ordinary: a balance that fails to read is skipped rather than written as zero (M9), a pool token holding no share of the pool emits no leg at all, and a whole smart side vanishes when its DEX state fails to read. Judged per snapshot, the surviving row would make the side look single-base and hand a mixed pool to a currency view for a tick and back. A same-base NFT is unaffected: a wstETH/ETH T1 NFT is an ETH-book carry, a USDe-USDT smart pool is USD-book, and an ETH-based pair funding a USD-based borrow is cross-asset.
The same holds for a pool token in no currency book, and for the same reason. Such a token is a base of its own in the side's set, so an NFT holding a leg on a side whose pair includes one is judged on a two-base side at every tick — including a tick where only the booked token's row happened to appear. Judged per row that tick reads as an ordinary single-currency position, and the booked token's value growth over it is the pool's composition drift against an asset the product cannot chart. The set reads each asset's latest resolution in the loaded history, which is today's registry: an asset covered since charts (including the rows written before it landed, which carry no book of their own), and one dropped from the registry does not. The M9 evidence rule extends here too — a leg observed on both sides of a snapshot counts as held at it, so a dropped row does not move a verdict for a tick.
What it cannot see. The evidence is positive: a vault is judged from the assets its legs were observed holding. Three residuals follow, and none is closable from the read path today because no address-derived source of a vault's pair exists there — carry_registry carries display labels and a lifecycle status, not the pair's token addresses.
- A pool sitting entirely on one token for the whole loaded window emits one leg per snapshot, so a two-base vault reads single-base and charts until the pool rebalances. Most likely on a freshly registered wallet whose replay window is a snapshot or two.
- The verdict is derived from the rows one request loaded, live tip included, so evidence that arrives only in a live read can flip a classification between page loads.
- It is scoped to one wallet's rows (one context per wallet,
loadContext), so two wallets holding NFTs of the same vault can in principle reach opposite verdicts in one "all wallets" response. Widening it to the union of a request's wallets would trade that for a worse inconsistency — a wallet's own page disagreeing with its contribution to the aggregate, which sums per-wallet curves (aggregateBookHeadline) — so the scope stands.
The durable fix is a persisted per-vault pair: the reader already decodes supplyToken0/1 and borrowToken0/1 from every position payload, so recording them would make the verdict address-derived and complete. That is a write-path change and is not in this read-time work.
Fluid flow coverage (FWS3). FWS2 held every Fluid leg "Outside the yield book" behind an interim gate, because a value-accrual leg charted without flow coverage would book any interim deposit as pure yield (the newborn-value-leg defect class fixed for Pendle in #348). FWS3 landed the Fluid flow scanner (M15/M16 below), so the M1/M14 rules above decide inclusion and Fluid legs chart; the gate and its reason were deleted in #909.
M15 — Fluid liquidations (FWS3)
Fluid has no per-position liquidation event: LogLiquidate(liquidator, colAmt, debtAmt, to) is VAULT-AGGREGATE (it carries no nftId), and a liquidated position emits nothing of its own. So a per-position liquidation is detected by state diff with LogLiquidate as the cheap trigger (derive/fluid-state.ts): for each block in which a vault emitted a LogLiquidate, every tracked NFT on that vault is diffed across that block alone, and an NFT whose collateral OR debt DECREASED across it, with no LogOperate on that nftId in that same block (a real withdraw/repay would emit one), is a liquidation. It is booked as the venue's own movements, M4 — never a withdraw/repay: a seizure on the collateral side and, where debt was cleared for it, a debt_relief on the debt side, sharing one link_id under link_kind = 'liquidation'. One row per LEG that moved, not one per side: the run walks every leg the position holds across that block, so an ordinary NFT emits the two above while a smart collateral side decomposes into one seizure per pool token (2–4 rows for a smart pair). A cascade of partials repeats the whole set per block, each in its own curve interval. Each row is written at the leg it moved — position_key is that leg's own key (fluid:vault:<addr>:nft:<id>[:<token>]:<side>) and asset is that leg's own asset, so the debt_relief row names the debt token; the 5-part NFT group prefix travels beside them in meta.positionScope, which is the scope the residual is attributed at. Provenance for idempotency is the tx_hash + log_index of the last LogLiquidate in that block.
Both the detection and the value are anchored on the liquidation block, never on the scan window. A reading at a block returns the state after that block, so the one-block diff contains exactly what that block did — which is the only thing an owner operate in that block could explain. Judging instead on "was this position operated anywhere in the window" lost every seizure on a position its owner had touched: measured on prod, a position liquidated in nine steps over two days drew nothing on either line and wrote no row at all, because its owner had made a routine deposit a day earlier. It also made the answer depend on how wide the scan happened to be, so the 6h refresh and a 90-day replay disagreed about which liquidations exist. Block-anchoring makes both produce the same rows.
And the PRICES are anchored there too (2026-08). Block-anchoring the detection made the seized quantity exact; the valuation was still asking for redemption rates as of today, which for a wrapper with no on-chain rate getter means the freshest recorded rate whatever block is being valued. A months-old seizure was therefore priced at today's rate, and because the quantity beside it is an exact state difference, the product was a confidently wrong figure rather than a visibly missing one. Every rate on this path now resolves as of the liquidation's own block, the same block the market half of the valuation already used. Historical Fluid seizure magnitudes move as a result, in either direction, by the amount the relevant wrapper's redemption rate has moved since the liquidation.
The book a seizure is quoted in comes from the token registry, not a built-in list (2026-08). The magnitude is expressed in the collateral's own denomination, so which denomination that is has to be answered the same way the position's own holdings answer it. This path used to answer from the code's built-in seed list while the holdings it nets against answered from the registry, and the two can genuinely differ: a token declared as excluded from the books exists only in the registry, so the seizure would be denominated in a book the position itself does not have. Both sides now read the registry.
A seizure's quantity is either right or absent, never rescaled (2026-08). The row records how much collateral was taken, in the token's own smallest units, which is a fact about the state difference and needs no lookup. Converting that into a human quantity needs the token's decimal precision, a separate on-chain read that can fail — and a failed read used to be filled in with the most common value (18). For a six-decimal collateral, which USDC and USDT both are and which is most of Fluid's supply side, that overstates the quantity by a factor of a trillion while leaving it looking like a normal number. The row now carries no human quantity at all when that read fails, keeps its raw amount and its loss figure (neither depends on the read), and says so in the logs so the read can be retried and the row re-derived.
A side of the position is whole, or its value is withheld (2026-08). The loss is the collateral that left minus the debt that was cleared with it, and each of those two is the total across every holding on that side. A holding can drop out of the total for reasons that have nothing to do with the seizure: its decimal precision or its pool composition could not be read, or its redemption rate has no recorded value reaching back to that block. Summing what is left would then report a side nobody measured in full, and the sum reads like any other number. Collateral short of a holding clamps a real loss to zero; debt short of one deletes the repayment and books the entire seizure, which is the overstatement this whole section exists to remove. So a side missing anything at all is withheld in full: the seizure is still recorded, still carries its raw seized amount, and reports no magnitude until the missing read is repaired and the wallet re-derived. This is the same choice made everywhere else on the write path, applied to a total rather than to a single figure. Anchoring rates at the seizure's own block (above) is what makes the recorded-rate case reachable, because the previous behaviour accepted any recorded rate.
And a withheld magnitude raises the same alarm a withheld penalty already raised. A seizure with no magnitude has no symptom on its own: the row is written, it carries its raw seized amount, and an absent loss figure looks exactly like a mark that has not arrived yet. Nor does the run fail — withholding is the correct outcome, so the job succeeds and anything watching exit codes sees a healthy run. So each withheld seizure is recorded with which of the four causes it was, and the causes reach an operator through the same channel the older two already used: two of them are registry gaps (the repaid reserve is not registered; the seized collateral has no book) that persist until someone edits a table, and the two added here are failed reads (the seized token's decimal precision, or a holding a side could not measure) that usually clear by themselves. All four are reported on every venue, which is newly true of the no-book case on the Fluid side: seizures there are built by their own scanner rather than by the shared valuation, so a seizure of a collateral with no book — bitcoin and gold wrappers, which is most of what that venue finances outside the yield book — used to withhold its magnitude correctly and then tell nobody. The message names only the causes that actually occurred and gives each its own remedy, because sending someone to a registry over a failed archive read is worse than saying nothing. Every write path says it: the 6h refresh, the deep-history backfill, and the on-demand refresh all report a withheld seizure the same way.
Two engine fixes made the loss land exactly once in the RETIRED engine, and are recorded here for the shapes they were measured on. Neither mechanism exists in the engine that serves the chart: there is no seizure-skip and no read-layer flow selection to admit a group prefix, because a liquidation is two ordinary rows on two legs and the netting falls out of the law (M4, M25). What the fluid keys taught is still live — a liquidation row's 5-part group prefix is not a leg key — and it is why the derivation writes a seizure per seized leg rather than one row per group.
seizedKeysByIntervalPREFIX-matches fluid keys. The seizure-skip inbuildBookCurve(which zeroed a seizedvalue/ptleg's value-series contribution so its drop was not booked as −yield and as the realized loss) matched the seizedposition_keyEXACTLY. A fluid liquidation row's 5-part group prefix never equals a 6/7-part leg key, so it never matched: a seized smart-collateral leg would double-count the loss and a seized smart-DEBT value leg would book phantom POSITIVE yield. The match is now a prefix match for fluid keys, covering all 2–4 value legs of a smart NFT.- The read-layer flow selection admits the group prefix. The reader selects a book's flows by INCLUDED leg-key membership; a liquidation row's group prefix is not itself a leg key, so it was dropped BEFORE the engine, the loss was never booked, and every smart value leg booked its seizure drop as yield anyway.
curveFor/getPositions/getHistorynow admit a fluid flow whoseposition_keyis the group prefix of any included leg (fluidGroupPrefixesOf+flowSelected, precomputed into an O(1) Set).
A seizure on a position financed ACROSS denominations. The liquidation row is the only flow in the ledger that names a group rather than a leg, so it owns no span, and since M22 a group can span two curves of the Cross-asset view — its collateral charting in one denomination and its debt in another. Admission (v2/segments.ts) therefore asks three questions, not one: does this curve hold a leg of that NFT; did it own the interval the row falls in (a curve the position had left, or had not yet joined, must not book an event that happened while it was elsewhere); and is the row's magnitude denominated in this curve's unit.
The last one bites. On a same-book NFT the equity destroyed is one number in one unit. On a cross-asset NFT the two sides are quoted in different units, and subtracting one from the other is a figure in no unit at all — exactly the FX blending M22 forbids.
The write path now converts before it subtracts (2026-08). The debt side of a cross-denomination position is expressed in the collateral's denomination first, off the same price observation that values the seizure — the same conversion a cross-denomination Aave or Morpho seizure uses — so the stored magnitude is a real figure in the position's own denomination. Unconverted, that subtraction produced a zero on the shape the campaign measured (33 ETH of collateral set against 48,931 dollars of debt clamps to nothing), i.e. a stored claim that a real seizure cost nothing; and on the mirror-image vault it produced almost the entire seizure, the same 19-to-20-times overstatement M25 removes elsewhere. If either side cannot be converted the magnitude is withheld (M9), never zeroed.
On the chart, a cross-denomination Fluid seizure now serves its magnitude in the denomination it was measured in (2026-08, D4). It was withheld from BOTH of the position's curves for as long as rows written before the conversion fix could still be in the table, because admitting one of those would have drawn the unconverted figure — which on the measured shape is a zero, i.e. a published claim that a liquidation that cost 1.23 ETH cost nothing. The release that lifts the withholding re-derives every registered wallet's history from the history floor as a one-time step, so no unconverted row survives it — and the read path checks that per row rather than assuming the step ran: a stored magnitude is served only when the row was written at or after the conversion fix went live, and a row older than that is withheld exactly as before. A wallet the sweep misses, or one that was un-tracked before it and re-added afterwards, therefore degrades to "no magnitude" instead of publishing a fabricated zero. What remains once every row is current is the honest rule, which is about units, not provenance: the curve running in the row's own denomination states the loss, and the other curve records the seizure without a magnitude, because there is no rate on that path turning ether into dollars. Both curves still mark the liquidation, every seized leg's value drop still stays out of yield, and the wire field stays nullable — other withheld shapes remain (an unpriceable cross-denomination bridge; a seizure whose debt side cannot be valued, below). A same-book NFT is unchanged: one denomination, one curve, one realized loss.
A seizure whose DEBT SIDE cannot be valued books NO magnitude at all (2026-08, D5). The penalty is the equity destroyed, seized MINUS repaid, and every liquidation repays something — so an absent debt side never means "the repayment was zero", it means the pipeline could not learn or price it: the repaid reserve is not in the lending registry (flagged at detection, debtUnvaluable), or the seized collateral is EXCLUDED so the repayment has no book to be expressed in. This used to fall through and book the full seizure as the realized loss, which overstates it by the whole repayment. Both marks are withheld instead (M9); the seized QUANTITY is still written, because that is known.
It is not silent, and both causes reach an operator. The valuation stamps each withheld row with which of the two it was and prints one [fail]-tagged line (the token run-cron.sh's alert grep matches), and the 6h job POSTs an aggregated Telegram alert. Each names the wallet plus the thing that has to be registered — the repaid reserve, or the seized collateral that has no book — and the remedy that matches it, then a re-derivation of the affected wallets. The wrapper truncates each alert line at 220 characters, so those identifiers lead the line and the prose trails: an alert that says "register the reserve" without naming the reserve costs the operator a session on the box, which is the whole cost these lines exist to remove. A successful tick exits 0, so the direct POST is the only path that can reach an operator.
isLiquidationMechanic (the Aave/Spark same-tx-mechanic exclusion) is a NO-OP for fluid (it early-returns unless the venue is aave/sparklend) and needs NO extension: a liquidated Fluid position emits no position-token Transfer to mistake for a user withdrawal.
M16 — Fluid NFT transfers (FWS3)
A factory ERC-721 Transfer between two wallets moves the WHOLE levered position. Collateral legs book transfer_out at the sender / transfer_in at the receiver, valued at the flow block. Debt legs book the SENDER as repay and the RECEIVER as borrow (valued at the flow block): a debt transfer_out would be signed −value with no side awareness (signOf, which knows the kind and not the leg's side), booking −(col+debt) instead of −(col−debt) at the sender and +2× debt phantom yield on a value debt leg. So the transfer nets to −equity at the sender and +equity at the receiver, with ZERO yield on every leg (pinned by a test). Mints (0x0 → user) and burns are NOT value flows — the same-tx LogOperate carries the value.
M17 — the opening transfer of a zapper-born position books nothing
Within one tx, for one nftId: if the tx carries a LogOperate for that nftId AND the position did not exist before the tx, the transfer path books NOTHING for it (the single gate every transfer-derived row passes through). It also drops mints (0x0 → user), burns, and self-transfers (from = to, which would otherwise emit transfer_out AND transfer_in for the same wallet under one log: two rows colliding on the flow PK, aborting the write).
A zapper opens a levered position by minting the NFT to ITSELF, operating (seed deposit + leverage-loop deposits/borrows), then transferring the NFT to the user in the SAME tx. Its from is the zapper, not 0x0, so the M16 mint skip does not catch it and the whole position was booked a SECOND time on top of the operate rows (collateral as transfer_in, debt as borrow). Live example (wallet 0xaae6ae86621e9d79346129784f61be34bd5ed050, tx 0xe4424fa8e2980c46d895c7218b7127a23d3df39745ef66db47a5c547a1c3800f, nft 18348, 2026-06-23): 10 flow rows where 6 are real, net signed flow +0.1804 BTC against a true opening equity of +0.09047 BTC. On a value-accrual leg (any smart/T2-T4 vault, which is what a zapper opens) landing inside the live series, the interval yield is Δvalue − netFlow = equity − 2·equity = −equity: a phantom loss the size of the whole position, which corrupts every downstream TWR sub-period. (A normal T1 leg accrues by index, and legIntervalYield ignores flows there, so a T1 double-book distorts the flow markers and net-capital display but not the yield curve.) The flow marker is wrong in EVERY case, including a position opened before the wallet's first snapshot — where the yield curve is unaffected, because a doubled birth fails M28's explanation test and so anchors nothing, leaving the old opening-balance behaviour in place: flowMarkersFrom (v2/markers.ts) nets every flow row of the tx with no interval filter, so the chart plots +0.1804 BTC of capital where +0.09047 went in. A fixed scanner does not repair a corrupted row: the cron and JIT flow writes are upserts that never delete, so an affected wallet must be re-derived through scripts/backfill-portfolio-wallet.ts (the only writer that deletes).
What M17 does NOT close (pre-existing, tracked separately): the gate keys on (tx, nftId), so a zapper that SPLITS the mint+operate and the transfer across two txs is still double-booked, and more generally buildTrackedNftSet attributes an NFT's in-window operates to its CURRENT owner, so a position bought second-hand books the previous owner's deposits to the buyer. Both need an ownership check on the operate attribution (was this wallet the owner at that block?), not a transfer-side gate.
Two things make the rule precise:
- Joined on (tx, nftId), not on tx alone: a tx that operates NFT A and transfers NFT B keeps B's M16 rows.
- The birth check (
fluidPositionOpenedInTx: apositionByNftIdread at block − 1, run only for the transfers that HAVE a same-tx operate, so a genuine hand-over costs no extra read). A same-tx operate alone does not mean the transfer is open mechanics: a pre-existing position handed to a new owner in a tx that also operates it (a bundled hand-over, or a zapper that keeps the NFT after a partial exit) is a real move of capital, and suppressing it would leave the sender's legs vanishing with no flow row — a phantom loss of −equity, the same bug class in the mirror. Only a position that did not exist before the tx cannot have been handed over in it. An unreadable position at block − 1 (the resolver reverts on an unminted nftId, and an RPC failure lands in the same branch) counts as BORN: that default reproduces the observed zapper open rather than the unobserved bundled hand-over, so a transient archive failure can never resurrect the +2x-equity bug.
The mint (0x0 → zapper) touches no tracked wallet, so the wallet-filtered transfer scan never sees it: that is why the discriminator is the birth check and not the mint log.
Fluid quoted rates — earned vs advertised (FWS4)
The positions table's differentiator column pairs each Fluid leg's REALIZED APY with the rate it is currently ADVERTISED to earn (quotedRateForRow, quoted-rates.ts; the four tables are loaded in quoted-rate-tables.loadQuotedRates):
- Normal (T1) leg — exact. The Liquidity-Layer supply/borrow APY of the leg token (
fluid_ll_apy, keyed bytoken_address) PLUS the wrapper's owntoken_yield_apycomposed on both sides. This mirrors the M14 realized attribution: the vault-exchange-price index carries the LL interest and the composed book redemption rate carries the wrapper appreciation, so the honest comparator isLL rate + wrapper APY. A supplied wrapper earns its appreciation; a wrapper owed as debt pays it — hence both sides (the same both-sides rule as an Aave wrapper leg). - Smart (DEX) leg — APPROXIMATE. The pool's
fluid_dex_apy.fee_apy_usd(keyed bypool_address) composed with the leg token's LL rate and any wrapper APY. The fee's sign follows the leg side, exactly as the carry engine composes it (carries-table.tssmartColApy = feeApy + weightedSup,smartDebtApy = weightedBor − feeApy): a smart-collateral leg EARNS the fee,fee + LL supply + wrapper; a smart-debt leg supplies debt-side DEX liquidity and ALSO earns the fee, so the fee reduces its funding cost,LL borrow + wrapper − fee(adding it would overstate the quoted debt rate by 2× the fee). The realizedvalue-series growth embeds all three (trading fees + the token's LL interest + any wrapper appreciation), but there is no single advertised smart-leg figure, so the column marks the number APPROXIMATE (a leading~and a tooltip). The pool fee is the defining component of a smart leg, so an unresolved fee (a not-tracked pool, or a failed on-chain DEX resolution) nulls the WHOLE quoted rate to a dash, never a fee-less number (M9). - The pool-resolution gap. A smart-leg
position_key(fluid:vault:<addr>:nft:<id>:<token>:<side>) carries the vault but not the DEX pool, whilefluid_dex_apyis keyed by pool.loadQuotedRatesbridges this by resolving each held vault's per-SIDE DEX on-chain once (readFluidVaultDexes→getVaultEntireData.constantVariables.supply/.borrow); a NORMAL leg's constantVariables address is the Liquidity Layer, mapped tonull(a normal leg never consultsfluid_dex_apy). A T4 can have different supply and borrow DEXs (vault 98), so the map is per side, never one pool per vault. The DEX is never guessed from the token pair.
Labels (FWS4). A Fluid leg renders both ids — the vault id (from carry_registry, names the market as on /carries) and the NFT id (names the position, since a wallet can hold several NFTs on one vault): weETH supply · Fluid #16 (NFT 9266). A smart (DEX) leg is marked LP, side col/debt: USDe LP col · Fluid #93 (NFT 9266). A vault absent from carry_registry degrades to the NFT id alone (… · Fluid NFT 9266). Wound-down annotation (D2). A wound_down / below_floor / blocked vault is ANNOTATED in the positions table (and on an All-view row for an NFT no currency view charts), never hidden — such a vault still holds live user debt (fluidStatusAnnotation).
M18 — entry basis and dislocation P&L (carry positions)
The shipped Basis column shows a carry's CURRENT market-vs-redemption gap (valueMarket − valueRedemption), which re-marks on every sync. It cannot say whether that gap moved for or against the holder since the trade was put on. M18 adds, in the expanded carry detail, the basis at entry and the dislocation P&L since entry, so a credit analyst can see how the secondary-market gap has moved since the trade was put on.
M18's derivation is superseded by M32, which is what serves this today (deriveEnteredBasis, src/lib/portfolio/v2/entry-basis.ts, over the wallet's whole leg corpus at once). The question and the formula are unchanged; what follows is the retired reader's version of both, kept because M32 is written as a diff against it — its module (src/lib/portfolio/entry-basis.ts, deriveLegEnteredBasis) is deleted, and the doctrinal note at the end of this section is the one M32 replaces.
Because every flow is already valued in BOTH marks at its own block (M5), no new data is needed — it derives at read time from the flow ledger. Per leg:
signedFlowBasis(f) = signedFlowValue(f.kind, f.valueMarket − f.valueRedemption)
legEnteredBasis = Σ signedFlowBasis(f) over the leg's flowssignedFlowValue (M3) makes the figure equity-signed: a collateral deposit at a discount enters NEGATIVE basis; debt borrowed below par enters POSITIVE basis (a liability cheaper than par). So a fused carry's entered basis is the plain sum of its legs (positionEnteredBasis, signed-in-model.ts), and the dislocation P&L the UI shows is positionRawBasis − positionEnteredBasis — a positive number means an exit at today's prices captures more dislocation than at entry. Partial unwinds realize the dislocation on the exited slice with no extra bookkeeping (an exit flow's signed basis nets against the entered total).
Mirror-precise entry marks (Milestone C). Each flow's MARKET mark now comes from the Dune price mirror at the flow's own minute (the newest common bar ≤ the flow ts, priceInBookFromMirror), replacing the old 6h-bucket DeFiLlama context whose cross-vintage USD noise froze ±30–50bps of feed artifact into a leg's entered basis (the phantom wallet 0xef08…07c5, whose fused weETH-col / wstETH-debt carry read a spurious −0.82 ETH entered basis where the truth is ≈ +0.05). Both legs of a fused carry now divide by the same numeraire bar, so the ETH/BTC level cancels and the entered basis is the coherent same-bar residual, not a gross-notional × feed-noise number. Marks read the standing hourly-common-bar mirror (or a routed token's own bar), which is the whole grid. Coherence is by construction; a JIT flow detected before its bar exists settles on the next cron re-mark (the settle-on-next-cron semantic, M5.1).
Honesty rules mirror M9 and M34:
A flow missing either mark is skipped, never assumed par, and the figure is flagged
incomplete(the UI marks it~and footnotes it).A position that predates our flow window has no observed birth flow, so its opening snapshot's basis anchors the entry (with the leg's side sign), plus any later top-up flows, flagged
synthetic("reconstructed from tracking start"). The "was the birth observed?" test keys on whether a valued flow lands at or before the earliest snapshot of any marks (a position must exist to be snapshotted, so an in-window birth's acquisition is at/before its first snapshot). Gating on valued flows keeps an unvalued birth flow from suppressing the anchor; using the earliest-of-any-marks snapshot (not the earliest both-marked one) keeps a null early mark from misreading a genuine pre-window position's later top-up as its birth. No snapshot carries both marks → the entered basis isnull(a dash, never a fabricated 0).Liquidations (M4) and their same-tx seizure mechanics are excluded before the derivation (a seizure is not an entry);
signedFlowValuealso zeroes a liquidation kind defensively.PT-family legs enter at 0 by construction. The accrual mark (M34) is anchored at the holder's own fills, so a PT leg's CURRENT
market − redemptiongap IS its dislocation since entry, and its dislocation P&L equals the current basis (converging to 0 at maturity).The short-circuit keys on
venue === 'pendle', and #811 C left that alone deliberately — with two consequences worth stating rather than discovering. A PT posted as collateral on Aave, SparkLend or Morpho is NOT short-circuited, and since its accrual mark is now anchored at entry exactly as a bare PT's is, its entered basis is derived from its flows instead of being set to 0. (The reason the old text gave for that — a PT-collateral transfer flow's "redemption side is booked at par" — is no longer true of anything: no PT row is booked at par on either line.) And in the other direction, a bare Pendle leg that received CARRIED LOTS from a venue leg is still forced to 0 and understates its wedge. Both are behaviour the accrual line makes newly relevant rather than behaviour it changed; aligning the short-circuit with the mark is a follow-up, and it belongs with whoever revisits M32.Pinned-basis-class legs entered at 0 by construction (M19), while the class existed. An arb-pinned wrapper (
basisClass: "pinned", sUSDS the only declared member) had its MARKET mark set equal to its REDEMPTION mark at every flow and snapshot, so both marks were identical and its entered/current basis was 0 with no special-casing here. #810 Y2 retired the class: sUSDS is market-measured now, so its entered basis is the measured market-minus-redemption gap like any other wrapper, and the defisaver Morpho sUSDS/USDT figures below were computed under the old rule. What the doctrine leaves behind is the structure it shares with the PT rule above: the code never asks whether a wrapper is pinned, it just applies both marks, so a genuinely arbitrage-closed pair still nets to 0 without a branch.
Caveat (Aave/Spark accrued-interest bundling). As in flow netting, an aToken/vToken Transfer value bundles interest accrued since the user's last touch, so a per-flow basis on those venues is approximate at the accrued-interest level. Both marks are perturbed together, so the basis DIFFERENCE is noise-level, not a bias. M32 retires this caveat: the rebuilt engine takes the amount from the venue's own event, so there is no bundling left to approximate.
Caveat (exits net into "At entry"). legEnteredBasis sums ALL of a leg's flows, disposals included (signedFlowValue signs a withdraw/repay opposite to a deposit/borrow), which is required for the delta to net a partial exit's realized dislocation. A consequence is that after a partial unwind the displayed "At entry" is the entry basis adjusted for what was taken off, not the original acquisition gap (it can even change sign), so the strip's tooltip frames it as such.
Caveat (dollar figure, not a per-unit spread). Both "At entry" and "Now" are dollar amounts, so the dislocation P&L is Δ(spread × exposure), not Δspread × exposure. For a yield-accruing leg held at a constant discount the figure is therefore nonzero purely from exposure growth (the discount applied to yield accrued in-kind). That term is second-order (≈ basis% × growth) and small in dollars, but it means the number is not a pure "price move at constant notional" attribution; the tooltip says so and does not claim to isolate price from carry.
Not derived as yieldMarket − yieldRedemption: that identity holds only for pt/value-accrual legs; an index leg's yield is attributed from the composed-index ratio and deliberately ignores basis moves, so the subtraction would be wrong exactly on the Aave/Spark/Fluid index carries that matter most.
The curve it sits beside must open on the same event (M28). Because the entered figure is read from the FLOW rows, a curve that opened at the first snapshot AFTER the position was acquired reported a total-return-minus-accrual gap short by the whole entry-to-snapshot basis move, and the strip beside it did not foot. Since the birth anchor the two open on the same event and the identity holds to the cent for value/none legs.
Current-mark honesty. If a leg's CURRENT market or redemption read fails (M9 null, coerced to 0 in the fused net), the strip withholds "Now" and the dislocation P&L (a dash + note) rather than show a confident figure computed against a partial current basis. This is separate from the entry-flow incomplete flag.
Dune reconciliation (FWS5, dev-time cross-check)
Dune is a dev-time reconciliation oracle only, never truth. Once, during FWS5, the parameterized query q7490982 (fluid_nft_pnl_daily_v2) was run over 5 real NFTs spanning T1 through T4 (NFT 1 legacy T1, 18343 T1, 9266 T2 with its liquidation, 18557 T3, 18524 T4) and compared against our engine's resolver reads. The whole run cost 3.573 credits, well under the 200-credit budget. Every discrepancy is explained below, and every one favours our engine.
- Expected disagreement: Dune cannot see per-NFT liquidations. Dune's per-NFT balance is an EVENT SUM (a running
sum(coll_shares_delta)overLogOperatedeltas). Fluid emits no per-position liquidation event, so Dune's per-NFT collateral and debt never decrease on a seizure. For the partially-liquidated NFT 9266 Dune reportsnft_cum_coll_shares46,408,309,641,881,485,000, which matches the resolver's pre-seizurebeforeSupply46,408,309,641,881,478,144 to double precision, NOT the true post-seizuresupply42,345,540,526,148,139,088. We SHOULD disagree by exactly the seized amount, and we do (the delta equals thesupplyLiquidationthe Fluid API reports). Our resolver reads are truth; this is the plan's stated expectation, confirmed to the wei. - LL vs vault exchange price. Dune converts token amounts to shares with the Liquidity-Layer exchange price, which ignores the vault's supply/borrow rate magnifiers. We use the vault exchange price (M14). On NFT 1 (no operate for 100k blocks) this is a small drift: relative 2.4e-4 on collateral shares, 1.2e-3 on debt shares. This is exactly why M14 pins the position index to
vaultSupplyExchangePrice/vaultBorrowExchangePriceand treats the LL prices influid_ll_apyas quoted-rate inputs only. - Third-party matview coverage gaps. Dune's USD figures depend on a daily third-party matview (
result_fluid_user_reserves) that can have coverage holes. For legacy NFT 1 it reportscoll_usd = 0andpnl_usd = -332.74for a position that genuinely holds 0.109 ETH of collateral (our reader, round-tripped fromqtyRaw x index). The matview has no usable row for legacy vault 1, so the pro-rata collapses to 0 while the debt side still resolves, rendering a healthy over-collateralised position as pure debt with a fabricated loss. - Dune omits accrued interest. For NFT 18524 Dune's
nft_cum_debt_shares792,704,210,084,493,700,000 is the rawLogOperate.debtAmt, missing the interest accrued since the operate (ourborrow792,704,210,877,197,908,447). Our reader reads the accrued amount, so it is strictly more correct. Dune also never sees thedustBorrow765,841,398,163,786,432 on that position, and on that field the two now agree: the padding is not a borrowing and our reader excludes it as well (see The tick padding above). This note previously read "our reader sumsborrow + dustBorrow... so it is strictly more correct"; that half of it was wrong and is the defect the tick-padding rule fixes. - Do NOT adopt Dune's TWR. Their
nft_history_by_period_with_apylinks returns asexp(sum(ln(net/prev))), and its per-periodlnterm is NULL whennet_usd <= 0, so a wiped-out or negative-equity period contributes 0 instead of flooring at -100%. That is the samelinkTwrdefect PR #348 fixed (execution-log row 45: each surviving factor floors at 0 so a loss of 100% or more is a -100% period). OurlinkTwrfloors; Dune's does not. - Where the two AGREE. Where the matview has coverage and no liquidation occurred, the two agree within accrual and rounding: NFT 18343 (T1) collateral $12.09M and debt within 0.08% (one day of accrual), shares matching to 53 raw units; NFT 18557 (T3) smart-debt decomposition $78.14 vs Dune's $78.07; NFT 18524 (T4)
nft_cum_coll_sharesmatching oursupply2,478,878,829,328,490,233,856 exactly.
What we take from Dune is the unit conventions (1e12 exchange prices, 1e18 DEX shares), which this run re-confirms, and nothing else. The full run is recorded in scratch/DUNE-RECONCILIATION.md.
M19 — the pinned-basis class: RETIRED
Nothing is pinned any more. The class was removed by the valuation-policy change of September 2026 (#810), and this entry is kept because older rules, plans and stored-history notes point at it.
What it was. Some wrappers cannot sustain a secondary-market basis: anyone can redeem them instantly, permissionlessly, at the share rate, into an underlying that is itself hard-pegged to the book numeraire. sUSDS was the worked example, and the only declared member: an instant ERC-4626 redeem() into USDS, which the Sky PSM swaps 1:1 for USDC with real capacity. Any premium or discount is arbitrage-closed within blocks, so a measured deviation is feed noise rather than information. For such a token the MARKET mark was set equal to the REDEMPTION mark by construction, across every valuation surface: flow valuation, snapshot valuation, the batched market context, the live tier, the basis refresher, the Market Depth panel and the stored-mark repair.
A second, larger population wore the same treatment without ever being declared. A par accounting asset that was in no mirror registry had no hourly bar of its own, so whether a quote existed at a given timestamp depended on whether some earlier read happened to fetch one opportunistically. Marking off that is not "the market price", it is a coin flip between a stale bar and a null, and on a mark-to-market line that flapping IS the drawn line. Around fifteen assets sat in that state, and the honest mark for them was par, in their own book.
Why it went. Both halves cost the same thing, and it is the thing the class was protecting: MARKET was forced equal to REDEMPTION, so the asset's own dislocation could never draw, and the par assumption of its book was extended through it. A depeg of USDS would not have appeared in the sUSDS basis; a depeg of any of the fifteen would not have appeared at all. That was defensible only while the second population existed, and it existed only because of a coverage gap.
The gap is closed. Every idle asset now declares a price feed on its own registry row (see the classification section), and every one of them is marked off it. So the derived population is empty by construction, and sUSDS is market-measured like every other wrapper. The basis class has one value, market, and the pinned predicates are deleted rather than answering a constant false: UNCOVERED_PAR_ASSETS, parPinnedUnitPrice, marketPinnedToRedemption and isPinnedAsset are gone from the tree.
One case survives the retirement, and it is not a pin. An asset that IS its book's own unit — the ETH sentinel, and only it — is one unit of itself by construction and has no series to be short of. identityUnitAsset names it, and the composition function's identity branch (M5.2) is where it is answered. WETH is deliberately not in that set: it declares a feed, its bar IS read (the dollar conversion of every ETH-book figure is that bar), and its ETH-book price divides that bar by itself to reach 1.
What replaced the guarantee. Nothing needed to: the guarantee was that a wrapper's market and redemption marks agree, and what a reader actually needs is that they agree WHEN THEY DO and diverge when they do not. The divergence badge (M7) is that statement, and it can finally fire for these assets.
M21 — gap bridging: a coverage gap is never P&L
(M20 is documented inside M5.1 as the mirror-staleness bounds, so the numbering runs M19 → M21.)
A value / pt leg is attributed from the mark's own value series net of flows (signedΔvalue − netLegFlow, M2). That formula is exactly right while the leg is READ, and catastrophically wrong the moment it is not: a leg present at t_k and absent at t_{k+1} is attributed against v1 = 0, so its entire principal books as negative yield, and the unexplained-birth guard then books 0 when it reappears. The loss is permanent, and no on-chain event caused it. That is the 2026-07-21 incident (a wallet's ETH book fell 21.3 ETH because one 6h tick read its Morpho market with a stale, empty discovery bound). index legs are immune by construction (their yield is an index ratio, qty-agnostic) and none legs never accrue, so only the value-series accruals carry this exposure. The full incident analysis and the programme this rule belongs to are in the coverage-consistency hardening plan.
The rule is therefore that an unexplained appearance or disappearance is a coverage event, not a P&L event: book 0 rather than a number the ledger cannot support, and record why. The engine that serves the chart states it as the withhold family W1–W8 and W11 (M29): occupancy is read off the ledger rather than inferred from the size of a step, so a leg the reader lost sight of is not classified by comparing values at all. What follows is the retired engine's version — buildBookCurve (src/lib/portfolio/pnl.ts), deleted — kept for the incident it was written against and for the three transitions it named, which the withholds answer one for one.
Classifying a disappearance. The order is load-bearing: ask whether the flows explain the disappearance FIRST, and only then whether the key comes back. A real close never lands exactly on its last mark — the position moves between the snapshot block and the close block (up to 6h live, up to 24h on the backfill's daily grid), and a Fluid smart pair's two legs move AGAINST each other by design (pool displacement is a first-class realized-P&L component) — so the exit test is two bands, either suffices: the tight band below, or a flow of the right sign and at least EXIT_EXPLAIN_FRACTION (50%) of the position's last value.
- Explained → no
deathalarm, and the close's realized P&L is booked. If the leg never returns, it books at the death interval viasignedΔvalue − netLegFlowagainstv1 = 0— a withdraw of 103 closing a leg worth 100 books +3, a withdraw of 97 books −3 (staging's number). If the same position key reopens later (a Morpho market re-entered, a PT rolled), the death interval alone cannot tell a full close-then-reopen from a partial withdraw whose remainder was merely unread — both are flow-explained disappearances — so the leg is suspended WITHOUT an alarm and the whole-span bridge settles it (below): a genuine close-then-reopen books the close's P&L, a partial withdraw nets to its accrual. Either way the close's P&L survives. The pre-B2 code routed any comes-back through the bridge BEFORE testing explanation, so it booked 0 and cried wolf with an interiordeathanomaly — that is the ordering B2 corrects. - Unexplained → coverage gap, or a close the ledger cannot see? The curve holds the WHOLE series, so it looks ahead: a key that comes back was never closed (a coverage gap → SUSPEND: book 0, retain the last snapshot, record a
deathanomaly, keep accumulating the leg's flows across the gap, and bridge on return). A key that never comes back stays suspended — 0 booked, thedeathanomaly stands.
Bridging a reappearance. Attribute the rebirth interval against the RETAINED pre-gap snapshot as v0 and the net leg flows accumulated across the WHOLE bridged span (t_k, t_m] when the flows explain the span — the tight band (a pure coverage gap nets to the leg's genuine accrual; a mid-gap deposit or withdrawal nets out exactly). The span also attributes when the death itself was a genuine close (B2) and the reopen is a clean birth: the residual is then the close's realized P&L, which a real close routinely lands several percent from its last mark, above the tight band. If the span is NOT explained — a capital movement inside the gap whose flow row never landed (reachable because the JIT flow persist is best-effort under a TRY lock), OR a close whose REOPEN flow is missing — the curve books 0 and records a bridge anomaly. Without that guard the fix inverts the incident's damage into an unbounded phantom gain, which is the worse direction.
Unexplained birth. Generalises the pre-existing netLegFlow === 0 guard to the same band: a leg appearing with 100 of value beside a 0.5 dust flow is an opening balance, not a 99.5 gain. This branch can only ever SUPPRESS, never amplify. The disclosed cost is real: a genuine move of more than the band INSIDE the birth interval is forgone, which reaches a PT on the MARKET mark whose birth interval spans a rate move, and any value leg born inside a depeg window — over an interval that is up to 24h on the backfill's daily grid, not 6h. A bounded suppression was chosen over an unbounded fabrication. One consequence worth stating: the book curve is no longer a detector for a broken flow ledger, because it now neutralises any birth mis-stated by more than the band. That diagnostic lives in the flow markers, the entry basis and the netted-capital tiles.
A Fluid position is judged as a position, not as a token. A Fluid pool position's two tokens are borrowed and deposited together, and the pool re-mixes them between the transaction and the snapshot, so each token's leg is born carrying a different share of the pair than its own transfer named. Testing them separately failed on both while the pair matched to a tenth of a percent: measured on a real restructure, +2.5% and −3.3% per token against 0.10% across the pair. So the legs of ONE position whose births their own flows cannot explain are re-tested TOGETHER against their joint flows. If the position is explained, the difference is real profit or loss and is shared across those legs in proportion to their value; if it is not, every leg still books 0 and still raises the alarm. This widens WHAT COUNTS AS THE LEDGER for one transition — to the position the flows actually belong to, the same scope a seizure already uses — and changes nothing about the rule that an unexplained transition is never guessed at. An interval with no births is untouched, and a leg with an unpriced flow can no more join the group test than it can pass its own.
The position's band is its widest leg's band, never the two added together. What is being tested across a levered pair is the DIFFERENCE between a collateral leg and a debt leg, so measuring the allowance against their SUM would let a position pass on cancellation alone: a pair born at 100,000 against 97,500 with no flows at all would have booked 2,500 of return nobody earned, silently, and any position above about 96% of its borrowing limit qualifies — which is where correlated pairs and anything drifting toward liquidation live. The allowance is therefore the largest one any member would have been given on its own (toleranceReference over every member's own value AND its own flow, the max, never the sum), so it is bounded by a real leg's own band rather than growing with the number of legs in the position. That is still a WIDER band than the smallest leg would have had alone, which is inherent to asking the question jointly; what it cannot do is exceed what one member could already have claimed.
Every difference booked this way is reported. By construction it lands on a leg whose own ledger did not explain it, so each one is written to the operations log with its size, on both lines — the guard succeeding at a wider scope is still a claim worth auditing.
Withholding that difference was not free: on an actively restructured position it left the total-return line reading materially above the money the position had actually made, once per restructure, and wrote a warning line to the error log on every page load for that wallet.
The band. A transition counts as explained by flows when |signedΔvalue − netLegFlow| ≤ max(2% × v_ref, 1e-6) (flowsExplainDelta, constants GAP_TOL_REL / GAP_TOL_ABS), where v_ref is the largest magnitude in play (toleranceReference, one definition for all three transitions). A band and not an equality test: a leg partially withdrawn AND then dropped from coverage in the same interval looks flow-explained to a === 0 check, which books the un-withdrawn remainder as a phantom loss.
Liquidations win. The M4/M15 seizure skip runs BEFORE the bridge and clears any suspension: a seized leg's value drop is the realized loss (booked once as the penalty), never a bridged accrual, and a leg suspended and then liquidated cannot book the seizure twice.
Equity base. A suspended leg is excluded from bookValue (that column reports what was actually READ) but stays in the TWR equity base, because the sub-period return of a bridged interval is the gap's accrual over the capital that earned it, and during the gap that capital is exactly the suspended leg. Omitting it divides a whole gap's accrual by the residual book value: for the incident's shape (21.3 ETH dark, 0.097 ETH visible over a 35-day gap) that turns a true 0.33% into a 24,573% realized APY on that headline figure. Consequence to know when reading a chart: during a coverage gap the book value dips while BOTH performance lines stay flat. The dip is honest (we could not read the leg); the flat lines are the point.
Every leg is guarded now. index legs are immune to this failure on the ACCRUAL line by construction, so before the total-return line only the value-series accruals carried the exposure. M23 attributes EVERY leg from a value series, which makes this machinery load-bearing for the whole book on that line.
Documented invariant deviation. The engine's standing identity is bookedYield == Δ(book value) − netFlow per interval. While a leg is suspended the curve books 0 against a Δbook of −v0, so the residual is exactly +v0 — and it unwinds to ~0 when the leg is bridged back. An unexplained birth leaves a residual of the suppressed amount, which does not unwind. Both are the intended trade (the alternative is booking a read gap as P&L) and are pinned by tests, not "fixed". legYieldInvariantResidual checks the per-leg PRIMITIVE, which is unchanged; only the curve deviates.
Where it surfaces. pnl.ts stays pure and never prints. Every unexplained transition lands on BookCurve.coverageAnomalies as {kind: 'death' | 'birth' | 'bridge', positionKey, ts, value, atTip}. The read path (the reader) logs them as [portfolio] WARNING unexplained leg <kind> wallet=… key=…, throttled to one line per (wallet, kind, key, mark) per UTC day. atTip entries are never logged: a transition at the newest point of the series is dominated by two structurally recurring benign races (the 6h refresher commits snapshots and that tick's flows in separate transactions, and a JIT live tip can be read before its flow persist lands), and an alert people learn to ignore is worse than no alert. An INTERIOR transition is the incident's shape and the real signal.
legPerformance (assemble.ts) needs no mirror: it walks the consecutive EXISTING snapshots of one leg, so its interval pair straddles the gap and its flow window already spans it — the two paths agree across a gap by construction (pinned by a test).
Accepted residuals. A flow on the same leg key, in the same interval, that coincidentally lands within the band during a coverage gap can still mis-attribute up to the band. A gap long enough that the leg's genuine accrual EXCEEDS the band books 0 rather than the accrual (conservative; long gaps are the subject of the plan's gap- patching phase). Both are tripwired, not eliminated.
M22 — cross-asset: financed positions move views
A borrowing through which ONE currency does not run on both sides is a financed position: the collateral is not earning free and clear, it is pledged, and no single currency's book can state what the pair of them earned. Under M1's per-book partition alone the collateral charted as a clean unlevered yield in its own book while the bare-debt sub-group was exiled Outside with its funding cost attributed nowhere. Such a position is presented in its own view instead, and it moves there rather than being copied: a position is in exactly one view at any point in time, so the USD / ETH views are honestly unlevered.
That view is All (wire key ALL), which is also where every holding no currency view can report a return for is listed, under the band its own shape earns, and where every other position the wallets hold is listed alongside them — see Portfolio. "Cross-asset" remains the name of the classification below; what changed in 2026-09 is what the view DOES with these positions. It states their VALUE and no return at all: collateral, debt and a net, in dollars, under the band Cross-currency borrowing. The per-denomination curves this view used to draw are gone with it.
The no-return rule is about THESE positions, not about the view. Every OTHER position in All is a single-book position, and it is listed there by the same row its own denomination view lists it with, its own book's rate and earnings included: a dollar fund's rate is a dollar figure, stated in dollars. What M22 forbids is blending two currencies into one published return, and that is exactly what a cross-currency position's netted rate would be — collateral earnings in one currency less funding cost in another, landing in neither and leaving the exchange-rate drift out entirely. So the rate cell on those rows dashes, the band says why, and the page itself still publishes no return line of any kind (its chart is a value).
Membership (groupIsCrossAsset, pnl.ts), rewritten 2026-09-18. At a snapshot ts, let B be the set of bases over the group's collateral legs and its debt legs together — a base being the book the asset's registry row carries (USD, ETH) or, for an asset the registry books to no currency at all (a bitcoin wrapper, gold, a governance token, apxUSD), that asset's own standing as a base of its own. The position is cross-asset iff it has a collateral leg AND a debt leg AND B is not exactly one currency book. Presence based, no magnitude threshold, so a 90%-repaid loan is still a financed position and only full repayment reverts it.
The old rule asked whether some borrowed book was un-collateralised (some b ∈ D not in S). It answered the question it was written for and missed two whole shapes, both of which turned up on a real wallet: a bitcoin-collateralised dollar loan, where D = {USD} and S was empty of currency books, so the position read as a bare debt and lost its collateral entirely; and a bitcoin-collateralised bitcoin loan, where neither side carried a currency and nothing about the position was cross anything, yet no currency book could state its return either. The set test covers both without a special case, and it is the reason an enabled gold collateral beside an ordinary dollar loop now takes the whole Aave account with it: B = {USD, gold} is not one currency book.
Every leg of such a group is included and travels with it — including a leg with no currency of its own, which is listed inside the entry and netted with the rest rather than peeled off into a band beside it. The invariant the read path keeps is: an included leg is either in a currency book or inside a cross-currency group. Nothing else is included.
What moves is the venue's own unit of exposure, because that is what "pledged against" means on each venue:
| Venue | Unit that moves | Why |
|---|---|---|
| Aave, SparkLend | the whole account | collateral is pooled, so every supply of the account backs the cross-denomination borrow and charting any of it as clean is exactly the misstatement this rule removes |
| Fluid | one NFT | vaults are isolated, so a borrow against one NFT pledges nothing held by another; a sibling NFT in the same wallet is untouched |
| Morpho Blue | one market, carry only | markets are isolated, and the market's pure lend (…:supply) never travels with the carry: lender-side capital is never seized, so it is financed by nothing and keeps charting in its own denomination |
One exception to "the whole unit moves", and it is the same one it has always been: an Aave or SparkLend supply the holder has not enabled as collateral is carved out before any of this and judged on its own (#717 D2). It secures nothing, so it cannot travel with a borrow it could never be seized for.
A leg whose asset settles in no currency book was, until 2026-09-18, in neither S nor D: on Aave and SparkLend it kept the unknown-asset path individually while the rest of the account moved, and on the isolated venues it took the whole position onto that path. It is now a member wherever it sits — a base of its own in B, a collateral that can be seized, a debt that is owed — which is what makes the account statement add up to the account.
Fluid contributes each SIDE's base SET to B rather than one base per side, because a vault has exactly one collateral side and one debt side and a smart side can hold two assets at once. A normal side is a single leg and contributes that token's base. A smart side is a DEX pair the reader decomposes into one leg per pool token (M14), and contributes both tokens' bases: wstETH/ETH contributes ETH alone, USDC/ETH contributes two. So an ETH-based smart collateral funding a USD-based smart debt is cross-asset, which is exactly what this view exists for, and a USDC/ETH collateral funding an ETH debt is cross-asset too, where the old rule held it out of every view instead.
That retires the directional-pair carve-out (2026-09-18). A vault holding two different base assets on one side (Fluid ships such pools, e.g. USDC/ETH, WBTC/ETH) used to be a coverage verdict of its own: outside every view financed or not, reported as directional-pair. It is now a base mix like any other — a cross-currency borrowing when it borrows, Smart repo lending when it does not. The set is still taken once per vault SIDE over the whole history in scope (fluidVaultCoverage.sideBases), because the pair a side holds is fixed when the vault is deployed — see M14 for why a per-snapshot verdict is not merely imprecise but wrong, and why the grain is the side rather than the whole vault.
Two conditions keep the verdict from reading structure into an absence, both also applied by M1's rules so the two cannot disagree:
- a side whose legs were observed in the snapshots immediately before and after a ts still counts as held at it, on either side and for every venue that can hold two. The readers skip a balance they could not read rather than write a zero (M9), so a single dropped row is not a capital move — and without this a dropped supply would turn an ordinary same-book carry into a financed position for one tick and back, while a dropped debt would turn a financed position into an unlevered one and hand its collateral to a plain view;
- a position with no collateral at all — none observed at the ts and none carried into it — is not cross-asset, because there is nothing for the debt to be cross with. Its bare debt is Not covered (
cross-book, M1 rule e), listed and outside the All view's total. Charting that debt is what M1's R2 rule forbids, since a liquidation's write-off would read as an equity gain while the seized collateral's loss lands nowhere. Note what changed: a position whose collateral is merely un-chartable is no longer in this case at all. It has collateral, that collateral has a price, and the position is a cross-currency borrowing. Only an absence of collateral, or collateral with no price at all, reaches here.
The residual, stated rather than assumed away. The evidence above spans a single snapshot, and across a longer hole nothing in the classifier can separate a read outage from a genuine full repay or withdrawal — that is a flow-ledger question, and the classifier is handed no flows. So over a hole of two or more snapshots the two directions degrade differently, on purpose. A group left with debt and no collateral goes Outside: conservative, and the direction that would otherwise cost money. A group left with collateral and no debt reads as unlevered and charts in its own denomination view for that stretch, which understates how the collateral is encumbered. The second is the presence rule's known limit and is not specific to the isolated venues — a two-tick hole in an Aave account's debt rows has always left its supply sub-group looking unlevered in the same way. Closing it needs evidence this pass does not have.
No FX blending, ever — and since 2026-09 no RETURN on these positions at all. The rule is about a figure standing for two denominations at once and carried on every point of a series, whose value moves with an exchange rate nobody is exposed to. It used to be kept by charting one curve per denomination inside the view: ETH collateral accruing on an ETH curve, the USD borrow cost showing as negative yield on a USD curve, with no figure across them anywhere. Those curves are retired. A financed position now states its VALUE, in dollars, and nothing else — which is not a blend, because a value is answered at one moment by whoever holds two currencies, and the conversion enters at the moment it is asked about rather than riding on a difference between two of them.
What follows from that, stated plainly because it is a real subtraction: a financed position's funding cost is no longer reported as a return anywhere. A wallet's USDC financing cost on a cross-asset loop used to land on that view's dollar curve; it now appears only as the value of the debt it built up. This was the owner's decision (2026-09-10): a return that can only be stated per denomination, on a position whose whole character is that it spans two, was a figure readers could not act on, and the view that carried it had no answer to "what is this position worth".
The clean handover rule. A position that moves between All-only (cross-asset) and a denomination book, in either direction, must not have the crossing booked as return in the receiving book. The grid interval that CONTAINS the crossing belongs to NO denomination book: the receiving book's series starts at the next grid point at the position's value, as a flow-neutral arrival, and the leaving book's ends at the previous one. The case that forced it is a cross-asset liquidation, which takes the collateral to clear a debt in another denomination and leaves an ordinary supply behind: under the closing-verdict rule the interval, the seizure inside it and the pre-liquidation reading were all handed to the book the leftover landed in, so that book charged the whole penalty, drew a collateral the holder no longer had as its own opening value, and carried the red flag — for a position it had never financed. The flag is on the All chart instead, with no cost stated (see below). The rule fires only where the leg was HELD across the whole interval; a position emptied and re-acquired inside one is two capital events with their own receipts, and those receipts have to stay inside the receiving book's span.
The one carve-out, and what makes it a different thing (#781). A liquidation whose collateral and debt are in two denominations is netted across them, at one rate, and the netted figure is booked on the COLLATERAL's curve. The rule above forbids a blend: a figure standing for two denominations at once, carried on every point of a series, whose value moves with an exchange rate nobody is exposed to. This is not that. A liquidation is a single exchange at one block — collateral left the reader and debt was extinguished in return for it — so the two legs of the trade are priced against each other at the moment it happened, and the conversion enters the series ONCE, at that instant, as realized P&L on a settled trade. Precisely:
- What is converted: the
debt_reliefalone, in the debt's own book unit. The seizure is never converted; it is already in the collateral's unit. - At which price: each book's own numeraire, priced in dollars at the liquidation's own moment, read from the price mirror (M5 — the only historical market-price source in the mark pipeline; protocol oracles are never a PnL mark). The dollar book's numeraire is the dollar, exactly 1 with no quote; the ether book's is WETH. So
R' = repaid × usd(debt book) ÷ usd(collateral book). Read at REQUEST time from the mirror rather than off the storedvalue_usdcolumn, which is the same level frozen at whatever bar was newest when the row was written, never trued up, and carrying no timestamp — so a reader taking it could neither refuse a stale price nor say how old the one it used was. Nothing new is stored. - On which curve: the collateral's. It books
−(seized − converted repayment), once. The debt's curve books NOTHING for the pair; its interval keeps the day's financing cost and nothing else. - Why the two are one figure and not two: the pair of adjustments nets to zero in dollars, so no value is created or destroyed by the conversion. It moves the write-off from the curve it happened on to the curve the loss happened on, which is where a reader can act on it. The netting refuses a group whose two adjustments fail that identity, which is the only check the netting has — §6.1's is per book and cannot reach across two.
HOW GOOD THE PRICE IS, AND WHY THAT MATTERS MORE HERE THAN ANYWHERE ELSE. This is a LEVEL and nothing about it cancels. M5.1's same-bar cancellation is a property of a RATIO of two quotes from one bar; converting dollars into ether is a single quote and carries all of that quote's error. Two consequences, both stated rather than assumed away:
- The error is amplified, by
converted ÷ cost. Sincecost = seized − repaid/p,d(cost)/cost = (converted/cost) × dp/p. At the ~5% bonus an ordinary liquidation carries that is roughly twenty times: a 1% error in the price is a ~19% error in the published cost. Across-book-seizure.test.tscell perturbs the price by 1% and asserts the published cost moves by that factor, so the amplification is a measured property rather than a claim. - And it is biased, not merely noisy. A liquidation happens while the collateral is falling, so the newest quote at or before the event is systematically above the price the trade settled at, and the published cost is systematically overstated — on exactly the event this exists to state honestly.
So the quote's age is measured, bounded and disclosed, and the bound is one hour — its own number rather than either of the mirror's, because neither of those is an accuracy budget. BAR_STALE_HARD (48h) is a walk-back ceiling and BAR_STALE_SOFT (6h) is a health threshold: M5.1 is explicit that a bar older than the soft one is still SERVED, because the marks it feeds are ratios whose vintage cancels, or absolute prices of things that barely move over hours. This is the one figure where nothing cancels, so the bound is sized from the amplification instead: the sensitivity is exactly 1 ÷ bonus, so holding the published cost inside ±20% needs the price inside ±1%, which is about an hour of ordinary ether. The mirror's standing granularity is hourly, so a healthy read is inside that by construction; past it the event is REFUSED and falls to W3.
And the bound is now the only thing between a stale quote and a sign-flipped figure, which is why it is sized this way. The converted > seized guard that used to catch one is gone (M25 — a negative cost is a real shape), so the age bound took its job on. Measured on the deterministic fixture's own event — 23.750000 WETH taken, 82,445.334015 repaid, true price $3,652.00, true cost 1.174607 WETH — a quote five hours old and 5% below settlement publishes +0.0136 WETH of gain on a liquidation the reader lost money on, and 5% is an ordinary six hours in a cascade. At one hour a sign flip needs the price to be wrong by the whole liquidation bonus inside one hour, which is a crash rather than ordinary movement. What the bound does NOT buy, said so nobody reads more into it: it bounds the quote's AGE, not its truth. A fresh quote that is wrong is undetectable, and no bound changes that.
The age of the quote that WAS used rides on the served liquidation marker (convertedAtPriceAgeSeconds), so a converted figure is never published without a reader being able to see what it was converted at.
Where the netting still reaches, now that the cross-asset curves are gone. It is applied to the ENGINE's bookings rather than to a view, so it still corrects the per-LEG cumulative figures every position row on the wire carries (legPerformance is view-agnostic). What no longer happens is the netted cost reaching a CHART: a financed position charts no return line, and under the handover rule a cross-book liquidation cannot fall inside a denomination view's span either. The All chart flags the event with no magnitude at all — a cost is a return statement, and that view states none — and the value line drops through it on its own.
The ACCRUAL line is untouched by all of this: both rows stay flows there, both movements net out, and the interval keeps ordinary interest (M24). And every other figure in the view stays un-blended: no cumulative total, no cross-denomination roll-up, no converted value on any point of any series.
If the conversion cannot be made, the event is withheld rather than netted at a guess. Five refusals, and each falls to W3 incomplete-seizure — every leg the seizure moved books 0 for that interval and a withhold row records why:
- no quote for one of the two books at that moment, or one older than the one-hour bound above;
- a group whose collateral, or whose debt, spans two books (no single rate nets it);
- a group any leg of which is outside the books — an
EXCLUDEDleg, the foldparseBookgives the retiredBTCbook among others, or a leg the coverage notes already cover (CN-3 / CN-4). The readable half must not go on booking the write-off alone; - a seizure or write-off carrying no market mark, or a seized collateral that is not a positive amount in its own book (the pro-rata split across several collateral legs divides by it);
- a pair of adjustments that do not cancel in dollars.
It never falls back to the un-netted figures, and never to a silent zero. The SIGN of the answer is not one of the refusals: see M25.
A leg's book is read through parseBook, the one DB boundary, exactly as every other read-path consumer of the same spine rows reads it. That is load-bearing rather than tidy: the numeraire map prices every non-dollar book against ETHER, so a stored BTC row taken at its word would be converted at the ETH/BTC ratio and publish a cost out of nothing. If a third book ever returns, that map has to be fixed before this rule can apply to it, and it is written exhaustively over the Book type so the compiler says so.
Membership is time-varying, and evaluated only on the snapshot grid (computeViewSegments, segments.ts). A borrow moves the position from that tick; history already accrued in the plain view stays there untouched. An inter-snapshot interval (t0, t1] belongs to Cross-asset iff the account is cross-asset at either endpoint — conservative on purpose, so an interval that carried cross-denomination debt for any observed part of it never contaminates a plain view. Three consequences, all intended: the tick containing the borrow is the boundary; a borrow and full repay inside one tick never register; reversion happens at the first snapshot where the bare book's debt reads zero. Every interval is owned by exactly one view, and the boundary snapshot is the last point of the departing view's curve and the first of the receiving one's.
Transition accounting. A leg that changes view dies in one curve and is born in the other. For an index leg that is already safe (yield is an index ratio and a newborn is an opening balance), but a value / pt leg is attributed net of flows (M2), so an unexplained death books its whole principal and raises an M21 coverage alarm. So each side of a boundary gets a derived transition flow of the leg's value at that boundary, in both marks (a mark that failed to read propagates as null, M9): out of the departing curve one second past its last point, into the receiving curve at its first point, so each lands in its own death or birth interval. The pair nets to zero across the two views. The kind follows the side, because the sign convention does: an asset leaving is a transfer_out and arriving a transfer_in, while a debt leaving RAISES the book's equity (repay) and arriving lowers it (borrow).
These flows are derived at read time and never persisted — nothing about this feature writes to the database, and there is no schema change. They carry synthetic: true and a reserved xfer: tx hash that no ledger row can hold, and they reach the curve engine ONLY: they are excluded from chart flow markers, the events feed and entry basis, because they moved no capital.
A pair is derived only where the leg is observed at the shared boundary snapshot. A leg whose observations stop under one view and resume under another did not move: it closed and reopened, each event carrying its own ledger flow, and a derived flow beside one would book the same capital twice.
Each visit to a view is its own series. A position that leaves a view and later returns (a borrow then a full repay) is handed to the curve engine under a distinct key per visit. Sharing one key would let the engine pair the snapshot before the departure with the snapshot after the return, which for an index leg books the whole financed period a second time in the plain view, and for a value leg holds the position's equity in the plain view's return base while the cross-asset view holds the same capital in its own. A gap in the leg's OWN observations is not a visit boundary: that is a coverage gap for M21 to bridge, and the two mechanisms never meet on one series.
A view's realized APY is diluted by time spent outside it. observedSeconds is the curve's own span end to end (which is what keeps the 30-day gate from resetting on every oscillation), while the linked TWR only compounds the intervals the view owns. A position financed for half of a 60-day window therefore reports roughly half its financed-period rate in the plain view, and symmetrically in the cross-asset view. Cumulative yield, book value and TWR are unaffected.
Invariants pinned by tests (segments.test.ts): over generated histories whose membership oscillates — including books whose only leg is the moving one — the per-view yields sum to the yield the position would have shown had it never moved, in both marks and for both accrual classes, with zero coverage anomalies and nothing suspended in any view; the TWR interval base shifts exactly as a real withdrawal or deposit of the same size would; a one-tick hole in a supply series never moves an account into the view; and realizedApy's 30-day gate (M8) reads the view's own curve span, so oscillating membership never resets the observation window. At the wire (ledger-v2-api.test.ts): the position is absent from the plain views and present once per denomination in its own, what the plain view keeps plus what the new view accrues is the whole unmoved history, a view a position has left reports the yield it earned but no book value (the capital is reported once, by the view holding it), and the bare-debt leg that used to sit in "Outside the yield book" is gone from it while everything else that belongs there stays.
The per-venue rules carry their own pinned cases, generated histories included: a Fluid NFT moves and returns while its sibling NFT in the same wallet never leaves its denomination view; a Morpho market's carry moves while its pure lend in the SAME market does not; a smart pair sharing a base moves with both sides accruing in their own denominations, while a vault of two base assets is charted by no view at all — at every tick, including one that carries only one of its two pool-token rows, which is where a per-snapshot verdict admitted it; and a Morpho market's value-accrual collateral crosses a boundary with zero coverage anomalies and nothing suspended, which stops being true the moment the derived transition pair is removed.
M23 — TOTAL RETURN: the mark-to-market line
The performance chart draws two readings of the same book, from one pass over the same intervals. This is the first of them, and it is the headline figure. The product decision, the full accounting specification and every deviation taken while building it are in the NAV rebuild plan.
Every stored curve restates on the release that ships this. Before it, the chart drew ONE line whose valuation basis the reader chose with a price-basis switch. That switch is gone; the chart draws total return beside accrual, and the accrual line is always the redemption-basis attribution whatever the reader used to have selected. An
indexleg gains price movement it never carried, so a figure a reader remembers WILL be different. Nothing about the underlying data changed; the question the chart answers did.
Per leg, then summed. Never book-level. Over an interval (t0, t1] a value, pt or none leg contributes
signedΔvalue − netLegFlow signedΔvalue = (v1 − v0) for an asset leg
−(v1 − v0) for a debt legwith v its MARKET value and netLegFlow its own MARKET-valued net flow over the interval. That is the leg-yield rule's value / pt branch generalised. An index leg is attributed differently and without its flows at all: see M27. The book-level form of the same formula — V(t1) − V(t0) − Σflows — is the one M21 calls catastrophically wrong: a book-level difference cannot see WHICH leg vanished and therefore cannot suspend it, so it books a coverage gap as a permanent loss of the whole position. The per-leg sum can, and does.
Every leg is now guarded, not a minority of them. On the accrual line an index leg is immune to the coverage-gap failure by construction (its yield is a qty-agnostic index ratio). On this line every leg is attributed from a value series, so M21's suspension / death / birth / bridge machinery goes from covering the value and pt legs to being load-bearing for the whole book, Aave / SparkLend / Morpho / Fluid supply and debt included. That is the correctness risk of the two-line model, and it is where the tests are concentrated.
An unpriced flow suspends the arithmetic, not just the alarm. If a leg carries a flow the pipeline could not value, its interval books 0 on this line rather than letting the whole value movement pass as return. (The accrual line keeps its existing behaviour here; see M24.)
A movement of nothing is worth nothing, priced or not. A line that moved no amount — a forced-deallocation fee, which burns shares and pays the holder nothing — is worth zero whatever the asset was quoted at that day, so it is booked at zero rather than left unvalued. Without that a fee charged on its own, on a day the asset had no quote, would suspend the day's return line for the whole position over a line worth nothing by definition.
The live tip is valued by the stored method. The "now" read applies a best-execution mid to the tracked-value figure, which is the right number for what this is worth if sold. A performance line may not change valuation method at its newest point, so the tip of this series is valued the same way every stored point behind it is. One series, one method, end to end. That includes a leg with no bar: where the mirror holds no bar inside the 48h walk-back, the stored tip carries no market value for it rather than the vendor's live level (the level no series holds, so a value computed at read could never reproduce it); the tracked-value figure at the tip keeps its live fetch.
Accepted boundaries in v1 (each pinned by a test, none silently absorbed):
- A position opened and closed inside one interval has no endpoint snapshot and contributes nothing to either line: that window's realized P&L is forgone.
- A PT whose TWAP reverts (a market too young to quote) books 0 on this line for that interval while the accrual line keeps accreting at the entry-implied rate.
- An idle par balance draws its own quote movement here. A real stablecoin depeg is genuine total return; the basis-point noise around par is accepted noise. Assets with no standing market-price coverage do not have this exposure at all (M26).
- An exit DRAWS the move between the last reading and the close, and nothing beyond it. The exit flow is valued at its own block, so for a
valueorptleg the difference between that value and the leg's last snapshot is realized P&L and is attributed as such. What is NOT captured is execution below the quote at that block: selling into a thin market for less than the price it was marked at there never draws. Anindexleg's exit draws nothing at all, because its flow value is not the capital that moved (M27).
M24 — ACCRUAL: the price-free line
Yield earned minus financing paid: what a position produces by being held, not what the market will pay for it. It is the existing per-accrual-type attribution (legIntervalYield), evaluated in the REDEMPTION mark, which is what keeps secondary-market pricing out of it (the one structural exception, a Fluid smart leg, is named at the end of this rule; the chart's explainer is worded to stay true of it rather than claiming "no price effect, ever" and taking it back here):
| Leg class | What accrues |
|---|---|
index | the composed index ratio times the leg's opening value; quantity- and price-agnostic |
pt | per-lot accretion at the holder's own locked yields (M34), never the TWAP |
value | the redemption value series net of flows |
none | nothing; a par balance does not accrue |
| any debt leg | the same, subtracted: funding is negative accrual |
The vertical distance between the two lines is the cumulative valuation effect over the drawn window. Both lines re-base to their own first in-window value, so on a one-week view that distance is one week of price movement rather than the whole life of the position.
A Fluid smart leg's accrual is the pool's economics, not pure interest. A smart collateral or smart debt side is a DEX pair, and its redemption series carries the pool's displacement P&L (M14) alongside fees and interest. That is by construction and it is accepted: for a smart leg "accrual" means what the pool produced by being held, which is the honest reading of a position whose two pool tokens move against each other by design.
A liquidation never draws here. See M25.
Known residual (pre-existing, not introduced here). A value / pt leg with an unpriced flow between two priced endpoints still books the whole value movement as accrual on this line; only the anomaly is raised. M23's line gates the arithmetic instead. Correcting the accrual line would move accrual figures this work is not chartered to move, so it is recorded rather than fixed.
An unpriced input suspends the arithmetic on BOTH lines when the arithmetic reaches across a gap. The two lines are ordinarily independent: a fixed-rate principal holding has no redemption price at all, so its accrual line withholds for its whole life while its total-return line books normally, and that is right, because an ordinary day is bounded by what the app actually read at each end of it.
One case is not bounded that way. When a holding has been unreadable for a stretch and the app then reads it again, it books the whole stretch in one day, from an anchor that can be as far back as the position's birth, and it subtracts every movement inside that stretch from the reading at the end of it. Those movements are the un-checkable part: the reading itself was read, but nothing corroborates the sum being taken off it. The one independent check available is the same subtraction on the other line against the other price, and if that line is withholding there is no check at all: the figure is unfalsifiable and unbounded in size. So a healed reading that spans movements, on a holding whose other line is withheld, now withholds too, on both lines, recording the figure it refused and the stretch it would have carried.
A healed reading that spans no movement at all keeps its figure, and the distinction is the whole of what makes this narrow rather than blunt. Between two readings with nothing in between, the day's figure is one reading less another, both of them read: it is bounded by two facts and a missing price on the other line says nothing about it. Withholding there would drop real, checkable return.
And what it withholds is dropped, not deferred. A healed reading is the only mechanism that restores an unread stretch's return, so a stretch this declines never re-enters the chart at a later day: the figure is gone rather than postponed. That is why a firing also raises an operator alarm carrying the amount refused, instead of leaving a flat day on the chart with nothing to say why.
Measured on the whole corpus, this fires on exactly two days, and both are the phantom above: minus $4,958,846.91 and plus $1,109,862.63, across stretches carrying eight and sixteen movements. Once the missing movements are recorded there is no gap left and it fires nowhere at all, which is what makes it a tripwire rather than a change in what the app reports.
M25 — a seizure draws once, and only on total return
A liquidation is a valuation event, not yield: collateral is exchanged for debt at a penalty, and the mismatch is a realized loss. Two rows record it — the collateral seizure and the debt_relief the liquidator paid for it, sharing one group id — and the two lines read them differently, in one pass:
- TOTAL RETURN — neither row is a flow, so the seized collateral's fall and the written-off debt's rise are both return: the line books seized minus repaid, exactly once, with no penalty column anywhere in the arithmetic.
- ACCRUAL — both rows ARE flows, so both movements net out and the interval is left with ordinary interest. Nothing about a seizure is yield.
The two rows are what makes that work, and writing the liquidator's repayment as an ordinary repayment instead is the classic way to get it wrong. A repay is in the total-return flow set, so it nets out — which collapses −(seized − repaid) to −seized and overstates the borrower's loss by the whole debt cleared. Measured on three real seizures — 14.376386 ETH drawn against a true 0.748885, 8.325848 against 0.431757, and 1,322.750 against 65.966 — that mistake drew 19 to 20 times the real loss, and on the largest of them a 5% liquidation read as a 90.6% one-day drawdown on a position still worth 135 ETH.
A cross-denomination seizure is netted ACROSS the two books, at one rate, and booked once on the collateral's (#781). −(seized − repaid) is a subtraction the law performs only where both rows are in one book. Where they are not, each row's own value move lands on a different curve and nothing subtracts: the collateral's curve books −seized, a loss far larger than the true one, and the debt's curve books +repaid, a gain that did not occur. On the two liquidated fixture wallets that was +82,440.81 and +21,746.90 of cumulative yield on the day of the seizure, and the same series drove the leg's Yield earned and realized-rate columns (+$80,107 and +168.5% on the Morpho debt leg).
So the pair is netted before it is published:
| what it books | |
|---|---|
| the collateral's curve | −(seized − repaid′), once, in the collateral's own unit |
| the debt's curve | nothing for the pair; the interval keeps its financing cost |
where repaid′ is the write-off carried into the collateral's book at the price of the LIQUIDATION'S OWN MOMENT. The price, how precise it is and how much its error is amplified, why an exchange at one moment is not the blending M22 forbids, and the five refusals that fall to W3 instead are all in M22's carve-out, which is where the no-blending rule itself is stated. The same-book case is untouched: the two rows are in one book, the law's own subtraction already nets them, and nothing here applies.
THE SIGN OF THAT FIGURE IS NOT A GUARD. repaid′ LARGER than the collateral taken is a real shape, not a broken rate: Aave writes a bad-debt write-off (DeficitCreated) under the liquidation's own group id, and it is emitted precisely when the borrower's collateral is exhausted — so the debt cleared exceeds the collateral taken by construction, and the borrower is genuinely better off by the socialised difference. The same-book law already books that as a net gain, because both rows sit outside the total-return flow set and it simply subtracts. The netting books the same money across books, whatever its sign, so one event is not treated two ways depending on which books its halves happen to be in.
Where the netting is applied, and why not in the law. The engine's per-leg bookings are what spec §6.1's identity is checked against, and that identity is per (wallet, BOOK, interval, line), recomputed independently from the stored rows at float epsilon. A netting that spans two books cannot live inside a per-book identity without making both legs of every cross-book liquidation fail an alarm that is not wrong. So the law keeps booking what each leg's own book saw, the alarm keeps checking it exactly, and the netting is applied once on the way to the published figures — where it is one enumerated adjustment (v2/cross-book-seizure.ts) rather than a branch in the law. Nothing is stored: there is still no penalty column anywhere, and a prod deploy restates a liquidated wallet's served history with no data step.
The chart's liquidation flag states what was TAKEN, netted per transaction, on the curve the collateral left. It is the magnitude the label beside it reads as, and it is a quantity the reader can check against the venue's own record. The magnitude's netting is the seizure rows alone — adding the debt_relief would net the flag toward zero on exactly the event the flag exists to show.
The flag itself is an EVENT marker, so it is drawn on every curve the liquidation moved a leg of (#782). A curve holding only the write-off used to be drawn with no flag at all, which left the reader looking at that chart alone with a step on the day they were liquidated and nothing beside it. It now carries the flag with no magnitude: the collateral taken is a quantity of the other denomination, and the debt written off is the liquidator's payment rather than what the event cost, so the honest figure there is absent rather than either of them (M9). A same-book liquidation is unchanged — both rows are on one curve, the seizure half claims the transaction, and one flag is drawn carrying the magnitude. A debt_relief carrying no liquidation group at all (Aave's DeficitCreated outside a liquidation: the liability was written off against the protocol's own deficit and nothing of the reader's was taken) draws nothing, and is netted with nothing.
A seizure whose collateral could not be priced marks the event and states no magnitude. The flag is drawn — the liquidation happened and hiding it would be worse — and its figure is absent rather than zero, because a seizure that cost nothing and a seizure whose cost is unknown are different statements and only one of them is true (M9). Every leg the seizure moved books 0 for that interval rather than the unpriced drop (W3 incomplete-seizure), so the line is flat across the event instead of drawing a loss nobody measured.
A CROSS-BOOK SEIZURE THAT FELL TO W3 KEEPS ITS FLAG MAGNITUDE, and the distinction is what the flag is FOR. Where the refusal is about the conversion — no price between the two books, a leg the books cannot state, a group with no single rate — the collateral itself IS priced, in its own book, and the quantity taken is a fact the reader can check against the venue's own record. So that curve's flag still states what was taken while its line stays flat across the interval, which says exactly the two true things: this much left, and nobody can say what it cost. What is absent in that case is the CONVERTED figure, not the seized quantity. The debt's curve states no amount either way.
M26 — the method is the registry's, never the tick's
One value per position: its market price, or its redemption value where no market price exists. Which of the two an asset uses, and where each comes from, is a property of the ASSET — one row in the token registry — and cannot change between two ticks of one series.
The row states four things, and between them they decide everything:
| what the row says | what it decides |
|---|---|
valuation | market (its own series), composed (own rate x the underlying's market), derived (through a wrapper's series), identity (one unit of its own book, by construction) |
feed | where the series comes from: the Dune tape, a pool-traded ratio, a vendor's aggregate, or NULL for no standing series at all |
rate_kind / rate_getter | where the redemption rate comes from: a block-pinned on-chain read at the leg's own block, or the stored six-hourly series where the rate is not readable on Ethereum at a block |
liquidity | whether a secondary market exists to be quoting it at all |
No code list decides either. Before this change the same asset was described in eight places — two basis registries, three mirror registries, the accounting-asset book map, the asset-profile list and the rate-getter map — each with its own membership rule, and three build-breaking tests existed to catch them disagreeing. They are all projections of the row now, and the coverage test enforces the two invariants that matter instead: one valuation method, at most one price source, per asset.
Changing a source is a registry edit plus a re-mark, never a per-bar switch. The measurement series MEASURES, the six-hourly job proposes, a person edits the row, and the token's history is reloaded from the new source and its stored marks recomputed. Code never picks a source per bar, which is what makes a stored series comparable with itself across its whole life.
Composition, and the two reasons for it. A redemption-priced asset's mark is its own share rate times its underlying's mark. It is reached for two different reasons and the reader is owed a different sentence for each: no market (nobody quotes it at size) and route-pinned (it trades, and its mint and redeem hold that price at the rate). The reason is DERIVED from the route the row declares rather than sitting beside it in a second column, so the two can never contradict each other. Either way its own bar never decides its value.
A fund share is never priced off a quote. Its value is its NAV per share times the value of what it holds, chained down through wrappers until a traded asset is reached. It gets no price bar, no secondary-market fact, no basis series and no divergence badge, because it does not trade. tETH and liquidETH are the worked example: both were on the Dune tape until this rule landed, and both stopped being priced off it.
No redemption-priced asset buys a price series, whether or not it trades. Its value is a rate times what it redeems into, so a stored bar beside it is read by nobody, and a declared series nobody marks off pages the dark-feed alert the first time a vendor goes quiet. What notices a market appearing is the weekly measurement of DEX trades and pool reserves, which reaches the venues directly instead of through our own tape. sGHO and sUSDf are the two that had to be argued about: both genuinely change hands, and both keep secondary as their liquidity class for that reason, because the class records what the asset IS and it is the declared fact the weekly measurement confirms or contradicts. What came off is only the series. USD3 went the other way, and it is the case that shows the test is not a one-way ratchet: its own mint and redeem are shut, a Curve pool trades it at its rate, so it is market-priced and takes the standing history chain.
Covered assets keep honest market bars. A real USDC, sUSDS or WBTC dislocation must draw, and does. A yield-bearing wrapper with no rate source is unrated and stays outside both lines entirely: no valuation can invent a redemption value, and a leg without one is skipped (M9), never defaulted to par. PT legs never consult this — they are valued by their own path (M34).
Deep history carries a caveat this rule cannot fix. Snapshot rows written before the minute-price mirror existed carry market values with tens of basis points of noise in them, and that noise draws on the total-return line. The stored-mark repair restates them; whether it has run on a given deployment is an operational question, not a methodology one.
A method change is a restatement, and the restatement is part of the release. Membership cannot change between two ticks, but a release that changes an asset's method changes the value WRITTEN for it from that point on, and rows behind the boundary still carry the old method — so the first interval spanning it books the difference as return, once, for every wallet holding the asset. Retiring the pinned class (M19) is exactly such a change, for the fifteen par assets that were marked at par and are Dune-marked now. A full re-derive of every tracked wallet is what restates them, and it runs with the release rather than after it.
M27 — an index leg's total return is flow-free
Aave and SparkLend position-token flow values are not the capital that moved. An aToken (or variable-debt token) Transfer emitted on a mint carries amount + balanceIncrease, and on a burn amount − balanceIncrease, where balanceIncrease is the interest that accrued into the balance since the user last touched that reserve. The level series is continuous and already carries that interest, so differencing the level and subtracting the flow removes it twice.
The damage is not marginal. A 1,000,000 supply held six months at +2.5%, half of it then withdrawn, books −24,994 of "total return" against a genuine +10; a full exit books a loss of exactly the interest accrued since the last touch; and the same bundling on the debt side prints a gain of the interest owed, so a financed position is wrong on both legs at once, in opposite directions. Nothing guards it: the leg is present at both endpoints, so M21 never looks.
So an index leg is attributed on this line without consulting its flows at all:
v0 × (indexRatio × priceRelative − 1) priceRelative = (v1/r1) ÷ (v0/r0)with v the leg's MARKET value, r its REDEMPTION value and indexRatio the composed-index ratio the accrual line already uses. The two factors are the leg's quantity growth and its price movement, and their product applied to the capital present when the interval opened is its return over that interval. This is the same structural immunity the accrual line has, extended to this line without giving up the price move — which is the whole reason index legs are drawn here.
Three consequences, each deliberate:
- Capital added or removed mid-window starts moving at the next window. Identical to the 6h-granularity convention M2 already documents for accrual.
- An index leg's exit draws nothing, because there is no closing value to compose against and its exit flow cannot stand in for one. The line holds at the last mark. A
valueorptleg's exit still books its realized P&L (M23): its flow value IS the capital that moved. - It withholds rather than invents. A missing endpoint, an unreadable market value, or a redemption value that is null or zero (so no price relative can be formed) books 0 for the interval.
M28 — where a series starts: the birth anchor
Superseded by M30, which is what serves this today. A holding's periods are cut by the ledger's record of when it was held, so an opening no longer has to be reconstructed from the first snapshot and a synthetic anchor row: a holding that opens and closes between two readings opens at zero as a matter of record. The section below is the retired engine's answer, and its module (birthAnchorRows, pnl.ts) is deleted; it is kept because the measurements it was built on are what M30 was checked against.
A curve is built on the grid its snapshots carry, and a flow is attributed to the interval some snapshot closes. A position opened inside the covered window therefore had its opening flows land before the first snapshot, where they were absorbed into the opening balance: the series started at the next grid point, and everything the position did between the two — up to 24h on the backfill's daily grid, up to 6h on the live one — was lost from both series.
That is not a rounding matter, because the entered basis shown beside it is read from the flow rows and not from that snapshot (M18), so the three figures the product states together stop adding up. Measured on a real Morpho carry (sUSDS collateral, USDT debt) opened 2026-06-30 13:45:47, first snapshot 2026-07-01 00:00:
| served | true | |
|---|---|---|
| Total return | +25.34 | +89.19 |
| Accrual | +206.02 | +210.37 |
| Total return − accrual | −180.68 | −121.19 |
| Dislocation P&L on the position | −121.19 | −121.19 |
The $59.49 wedge is the debt leg's price move between the borrow block and the first snapshot, swallowed by the opening balance while the entered basis it should have been measured against came from the borrow's own flow row.
The rule. When a leg's own flows explain the value it holds at the curve's first snapshot, that leg was born inside the window and its value at the flow block is known — the flow row carries it, in both marks (M5). The curve then opens at the book's opening, valued from the flow rows themselves, both series at 0 there. A book that predates the window has no such flow, and keeps the first-snapshot opening it has always had, whose entry anchor is the synthetic one entry-basis.ts already uses for exactly that case. Birth is per book, not per account: each curve opens on its own first position, and trackedSince (which is the first plotted point's date) follows it to the day the position was opened.
Which instant "the opening" is: the last of the book's in-window births, not the first. A book is rarely opened in one transaction — a wallet is funded and then the position is deployed; a manual Aave / Spark / Morpho carry supplies in one tx and borrows in the next — and the opening point has to carry the equity the birth interval earns on, because it is that interval's TWR base, which every linked return in the book is built on. Opened at the first birth instead:
| Book | opens holding | TWR |
|---|---|---|
| carry alone (one tx) | 14,473.85 | 0.77% |
| same carry, $5.00 of gas money funded 13h earlier | 5.00 | 749% |
| same carry, supply and borrow in two txs 60s apart | 322,155.41 (gross collateral) | 0.53% |
Anchored at the last birth, all three read 0.77%. (Measured while a book-level realized return was still published: on the same three books it read 0.77%, 2,238% and 0.03%. That metric has since been removed, M8b, and the anchor rule is unchanged by its removal because the TWR base is the same quantity.) Nothing is lost by starting later: a leg born earlier opens at the capital that went into it, so the whole span from its own birth to the first snapshot is still charted, in the interval the anchor opens. With one opening transaction — the shape this rule was measured on, and the common one — the first and last births are the same instant and this reduces to "the curve starts at that flow".
The opening point's label, when an opening sits inside the skew band. The anchor is chosen by block, so it is always the last receipt mined below the first read, and it is genuinely first on chain. Its timestamp need not be earlier than that read's label: a receipt inside the band described above (the interval is a block range) is mined before the read and stamped after the window the read is filed under. The series is drawn on labels, so an opening point stamped after the first grid point would be drawn second in a series it opens; its label is therefore clamped to one second before that grid point. Nothing that is netted moves with it, because every interval test reads the block. The alternative, declining to anchor there, would cost the whole book its birth interval whenever one of two opening transactions landed in the band, which is the ordinary shape of a carry opened in a supply and then a borrow.
The clamped label is the position's first read, not the instant of its opening transaction, and it is the weaker of the two by design: for an in-band opening it sits up to about fifty minutes before the transaction, and where the first window label is a day boundary (one window in four) the daily reduction buckets the opening point into the previous calendar day. That is the price of drawing a series in label order while netting it in block order, and it is paid on the presentation side rather than in any figure.
Two populations, one opening row each (birthAnchorRows, pnl.ts — deleted; see the supersession note at the head of M28):
| At the anchor instant the leg was… | Its opening value |
|---|---|
| born in the window | the signed sum of its flows at or before the anchor: the capital that had gone into it by then. A deposit and a borrow in one tx are ONE opening point, so a carry opens at its equity and never at its gross collateral; a deposit and a borrow in two txs reach the same opening point, because the anchor is the later of the two |
| pre-window | the first snapshot minus what arrived since the anchor — the only value that books zero over the birth interval, which is what an unobserved birth must book. Copying the snapshot verbatim would leave Δvalue = 0 against a positive net flow and draw a top-up as a loss. An index leg is copied verbatim instead, because M27 attributes it without consulting a flow at all |
No double count, and it lives in ONE place. The opening point CARRIES the birth flows' value, and those flows sit at or before the new first grid point, where the existing "a flow at or before the first grid point is an opening-balance flow" test already drops them. No second rule was added to make that true. Flows strictly after the anchor net into the birth interval as ordinary mid-interval capital moves, which is why the pre-window row above is reconstructed net of them.
The first interval's TWR base is the flow-valued opening, so a carry's realized APY is measured against the equity it actually opened with.
The birth verdict is not M21's band. M21's 2% asks "attribute this transition or bridge it"; the birth test asks "are these flows the leg's entire value", and its residual is not classified, it is charted — whatever the flows do not account for is drawn as the birth interval's P&L. On the measured carry the quantity this rule exists to recover over a 10h birth interval is ~$20 (0.006% of the leg), while 2% would admit $6,449 of unexplained value into that same interval. So the redemption side is gated on what the leg could plausibly have earned since the flow block: an absurd 200%/yr over the actual elapsed time (0.55% of the leg over a 24h birth interval, 0.23% over a 10h one), floored at 1bp for a birth minutes before a snapshot. A leg's redemption value is its claim in the book's own unit, so across a birth interval it can move only by yield, which is what makes a rate bound meaningful there; the market side keeps M21's band, because a market mark carries the wedge too. Both marks must explain the birth, so the tight test gates the total-return line as well. A $150,000 top-up onto a $2,000 balance an hour before the first snapshot no longer opens a series and draws that $2,000 as one hour of return.
Fluid smart legs are tested at the NFT, not the leg. A smart pool re-mixes its two tokens between the opening transaction and the snapshot, so each token's leg is born carrying a different share of the pair than its own flow named (measured on NFT 18076: +2.5% and −3.3% per token, 0.10% across the pair) and no per-leg test can admit them. The legs of one NFT that fail alone are re-tested together against their joint flows by the same predicate M21 uses for a mid-chart group birth, once per mark, and are born only if both agree. The group keeps M21's band rather than the rate-bounded one: its residual is pool displacement, not accrual, so a rate bound is the wrong shape for it.
What is deliberately NOT anchored (each keeps the first-snapshot opening): a leg missing either mark on its snapshot or on any opening flow (M9 — the verdict has to be mark-independent, or the accrual line and the market-valued total-return line would open on different dates); a pt leg, whose accrual curve is already anchored at its entry and whose entered basis is 0 by construction (M34); a curve carrying a liquidation between the earliest birth and the first snapshot, which is a valuation event no reconstruction can back out; and an opening flow that moved capital OUT on net at the birth instant. A double-booked birth (the M17 zapper shape, whose flow rows sum to twice the equity) fails the explanation test by construction and so anchors nothing.
Three of those withhold the anchor for the whole book, not just the leg that triggered them, so an unrelated position can cost a carry its opening point: a PT acquired inside the birth interval (historically, its par-valued flow row against its accrual mark reconstructed a negative opening, and copying the snapshot would draw the purchase as a loss — see the note below, the premise no longer holds); a pre-window leg whose post-anchor flows are not all valued in both marks (a $12.50 top-up whose price failed to read is enough); and the liquidation above. All three put the curve back exactly where it opened before this rule, so a withhold never fabricates anything — it only forgoes the birth interval.
The PT exclusion's premise is gone, and the exclusion is not. It was justified by a PT acquisition being "par-valued" on the redemption side: a par flow row against a below-par mark reconstructs a negative opening. Since #811 C an acquisition's accrual value is WHAT WAS PAID, equal to its market value at the fill block by construction, so the negative opening that exclusion avoided can no longer arise from a PT purchase. Removing it is a follow-up rather than part of that change: it hands a birth interval back to every book that holds a PT bought in its opening window, and that is a restatement of published curves which deserves its own diff and its own before/after.
An index leg born here is attributed from its value series over the birth interval (its opening row carries accrual: 'value', since no index reading exists at a flow block) and from its index ratio for every interval after. Left on the index path the ratio would be 1 and the leg would book no accrual at all over the birth interval — which is not symmetric across a carry: the sUSDS collateral of the measured position is a value leg and booked its full accretion while the USDT debt booked no funding cost, overstating the birth interval by the entire funding leg; and on an Aave / Spark carry, where both legs are index, the whole birth interval booked nothing. The value series is exactly recoverable here where the index ratio is not, because the flow rows are the leg's opening value, so Δvalue − netFlow is its accrual. It also makes the index and value paths agree over the birth interval, which matters because a leg is demoted from one to the other whenever a stored index column cannot be parsed.
Where it applies: the registration backfill, the live-signup path (a first position opened between two 6h crons), and a group that opened and closed inside the window, which starts its segment at its opening flow the same way and realizes its whole dislocation on exit. Per-leg figures run the identical rule one leg at a time (legPerformance, v2/attribution.ts) over the same flow selection, seizure rows included — a Fluid state-diff seizure is written at the leg it moved (M15), so the position row and the curve above it anchor the same leg over the same span.
What still does not foot, and why it is not this rule. The identity totalReturn = accrual + (currentBasis − enteredBasis) is exact for value / none legs and for an index leg's birth interval. It stays second-order approximate for an index leg's ordinary intervals, whose accrual is attributed from the composed-index ratio and deliberately ignores basis (the same caveat M18 records for the dislocation strip); the residual is Δredemption × (par − price) per interval, cents on a six-figure position. And a pre-window leg topped up before its first snapshot reads richer on the strip than on its own series by exactly the basis it opened at, because deriveLegEnteredBasis (the retired reader's entry-basis derivation) called a birth OBSERVED when any valued flow landed at or before the first snapshot, where the curve asked whether the flows explain the balance — the looser of the two tests, and looser again now that the birth band is rate-bounded. That leg's series is identical before and after this rule; the divergence is a property of the entered-basis test, pinned by a spec in birth-anchor.test.ts rather than papered over.
One property the anchor does not give the daily series. A bucket carries the last observation inside it (an end-of-period convention, see bucketReduceCurve), so on the 1d width a book whose first snapshot is not at UTC midnight has always opened at a non-zero cumulative — with or without an anchor. The anchor point folds away the same way when it shares a UTC day with the first snapshot. The 6h series, which is what trackedSince and the opening-at-zero property are asserted on, is unaffected: the anchor can never share a 6h bucket with a 6h-aligned snapshot.
M29 — engine v2: one law, occupancy, and the withholds
The law, once. Every leg, every venue, every boundary, every accrual class goes through one subtraction. There is no index branch, no fixed-rate branch, no birth branch, no exit branch and no bridge branch anywhere in it:
earnings(leg, b0, b1)
= sign(leg) x [ value(b1) - value(b0) ]
- SUM over the leg's receipts r with b0 < block(r) <= b1 of sign(r) x amount(r)sign(leg) is +1 for an asset leg and -1 for a debt leg. The accrual line reads each value and each receipt at its redemption mark and the total-return line at its market mark, and that is the only difference between them: one function produces both. So wherever the two marks coincide the two lines are numerically identical, which is the property M24's asymmetry across a coverage gap breaks today.
Two things about the interval are load-bearing. It is a half-open range of block numbers taken from the leg's own wallet's two bounding snapshot rows, never a range of wall-clock timestamps: a snapshot's label is not the moment it was read, and the two differ by roughly fifty minutes on a live tick. A read at block b returns the state after b is applied, so (b0, b1] is the exact complement of the two endpoint reads and every receipt nets against exactly one interval. And a receipt is valued at its own block, so capital put in or taken out mid-interval earns or stops earning from the moment it moved rather than from the next window.
Occupancy: whether the position was held is read from the ledger, not guessed from the numbers. The retired engine decided whether a leg was held by asking whether the numbers added up inside a tolerance, and every phantom this rebuild removed came from that question being unanswerable. The engine that serves the page asks a different one:
quantity(leg, b) = the balance recorded by the leg's last receipt at or before block b
occupied(leg, b) = quantity(leg, b) > 0
a series = a maximal run of blocks over which the leg is occupiedEvaluated at end-of-block granularity, so within one block the last receipt wins. That single choice settles four separate cases at once. A position passed through an intermediate wallet inside one block never opens a series there, so a router-mediated exit stops fabricating a deposit on the middle wallet. A position closed and reopened inside one window is two series by structure rather than by a tie-break. A position opened and closed inside one window is still bookable from its receipts alone, where today it contributes nothing. And absence from a snapshot stops being evidence of anything: occupancy answers whether the leg should be there, the spine answers whether we can value it, and the two are independent facts. No magnitude band survives anywhere in the arithmetic.
A leg with no movement record at or before the block holds nothing there: occupancy is the ledger's alone. A holding older than the ledger's first movement is stated by an opening record, written from the reading that first finds the leg (the wallet's first reading, or the later one where the leg's coverage starts), and never reconstructed from a later movement's two quantity columns. See Data pipeline for the audit that writes it.
The state table. Two facts per leg per interval, occupancy at each endpoint and whether the spine holds a row there, decide one of nine states: dormant, live, birth, exit, a read gap that has just opened, one that is being carried, one that has healed, an exit across a gap, and unanchored. A snapshot row for a leg the ledger says is empty is not a state of its own: the leg is dormant there, the ghost cross-check names the row, and the reading audit books the opening or correction that makes the ledger and the reading agree. A snapshot the leg IS in that carries no value in ONE of the two marks changes none of those four facts and so changes no state: the position is live, or exiting, or healing, exactly as it would be, and only the line that lost its price withholds (W2) and then bridges. One outage of a price feed cannot move a position out of the state its own ledger puts it in. A close and a reopen are walked as separate series inside whatever window they land in, which changes the position's identity, its entered basis, its vintage and its time-weighted sub-periods while leaving the interval's booked number exactly as it was: the walk telescopes. The identity advances on every close the ledger records, whether or not the window it fell in managed to publish a figure, because a re-entry paired with the snapshot from before the departure is the phantom this rebuild exists to remove.
A position acquired inside a window the spine could not read still books its return. The series is anchored at the receipt that opened it, and that receipt needs no earlier snapshot: the balance rose from zero there, so the opening value is zero as a matter of record. Without that anchor the whole stretch from the acquisition to the first successful read books nothing, permanently, even after coverage heals, and the missing return appears in no withholding record either. The same anchor is what lets a position acquired and sold entirely inside an unread stretch book what it actually made. A snapshot row that predates the acquisition is a row the ledger cannot explain, and it is never read as evidence the position was already held: that is what produced a large negative first-window figure with no alarm at all. The window books by the ledger, the ghost cross-check names the row at the opening endpoint of every window including the ones before a position's first receipt, and the reading audit settles the disagreement.
Where an alarm is stamped decides whether anyone sees it. An alarm observed at the newest point of a replay is treated as a transient race between the two writers and is counted rather than paged, because it heals on the next tick. Every alarm therefore carries the point its condition was observed at, never the point the replay happened to end at: a coverage gap that opened five windows ago is reported even though the replay ends today, while a gap whose only dark point is the newest one is the benign case the rule exists for. The count of what the rule swallowed is published on every run, so the decision to retire the rule is made against a number rather than a schedule.
The withholds: the complete list of places the engine books something other than the law. Each is recorded with a magnitude, which is what makes the reconciliation identity exact rather than approximate. W2 is one shape with two behaviours and they are listed apart, because only one of them is recoverable.
| Trigger | Scope | Booking | |
|---|---|---|---|
| W1 | the position was held and the snapshot holds no row for it at all | leg, the whole unread run | 0, suspend, carry the equity into the time-weighted base, bridge on return |
| W2 (endpoint) | a snapshot the leg IS in carries no value in this line's mark | leg, this interval, this line only | 0 on that line while it is dark, the other line books normally, the line books the whole stretch at the next reading it can be valued at, and the equity that mark could not see is added back to the rate base of every period whose STARTING reading was missing (see below) |
| W2 (flow, and the bridge) | a movement's amount is null in this line's mark, or the sibling line is withheld on a bridge that carries movements | leg, this interval, this line only | 0 on that line, and the figure is dropped rather than deferred. On the bridge the other line is already withheld, so both book 0 |
| W3 | a seizure moved a leg that could not be read or priced | the whole seizure scope | every leg 0, magnitude null and never 0 |
| W4 | a composite position that does not reconcile at position scope | the position | the residual attributed at position scope; 0 per leg if it still does not reconcile |
| W5 | the leg's opening anchor was withheld | the leg's opening | the entered basis degrades; neither return line moves |
| W6 | the ledger cannot state the leg's quantity: an anchor read was ATTEMPTED and FAILED (a movement with no balance), or the leg's first movement leaves it held without creating it and no opening states what it held before (unstated-opening) | leg, whole replay for a failed read; leg, the interval that holds the first movement for an unstated opening | 0, and a barrier: nothing bridges back across it. Never the absence of receipts (a leg with no records holds nothing). Production budget: 0 rows |
| W7 | a required coverage scope did not certify over the interval | the whole wallet | every leg 0 for that interval |
| W8 | a receipt whose pairing is ambiguous | leg, this interval | 0, and the row is reported |
| W11 | a reading found the leg's quantity differing from its movement record and the reading audit booked a correction for the residue its explanation could not account for (corrected-reading), or an opening restated a holding the ledger already carried | leg, the interval that ends at the correction's reading | 0 on both lines: the quantity moved at a moment nobody knows, so neither the holding's return over the stretch nor the arrival's is stated ("not measured", never a gain or a loss). The row records the correction's value, signed by the direction the quantity moved. Soft: counted, never paged here (the audit's own page is the alarm for an unexplained correction, and an accepted cause never pages) |
W9 (a ghost row) and W10 (a holding that changed size with no movement record) are retired. Both were the engine guessing between the ledger and a reading; the reading audit now books the opening or the correction that makes them agree, so the engine reads the ledger alone.
A holding exists from its first record, and never earlier by arithmetic. Whether a holding is brand new decides two things at once: which days it existed on, and whether its first day has a starting value at all (a holding that did not exist is worth nothing, and nothing needs no reading). The ledger answers both directly: a leg holds nothing before its first record, and a holding older than the ledger's first movement has an opening record written from a reading. The rule this replaces read the days before a leg's first movement by arithmetic — the balance a movement left behind minus what moved — and that is wrong wherever the two quantities do not describe the same thing. On a Fluid position's opening movement they do not: the amount comes from the venue's event and the balance from the venue's own reading at the end of that block, and the two differ by the vault's tick padding on a plain borrowing (see M14) and by the pool's composition on a two-token one. Read that way, a position the venue provably created that block looked like one that already existed, the difference — a billionth of the position, on one measured case — was invented as a holding for every day before it existed, every one of those days was marked unvaluable (W1), and the opening day reported nothing. No such day exists now.
And never earlier by assuming zero either. The same two quantities cannot be read as a zero start: a first movement whose balance is not its own amount says, by its own record, that the holding had something in it before — a partial withdrawal from a position opened before the history began, the drawn debt's padding on a Fluid borrowing. Valued from zero, that day would report the whole earlier holding as a gain. So where no opening record states what came before, the day that holds the first movement is "not measured" (W6 at that interval), and the holding reports from the next reading on; the reading audit books the missing opening as a correction at its next reading, and the registration's opening records prevent it altogether. Where the venue states that it created the position in that very transaction (Fluid's creation event), the zero start is the venue's word, and the opening day reports.
A two-token position's opening day is reported at the position's level, the same way its closing day is. These positions report jointly rather than per token (INV-6 above): the pool re-mixes between the movement and the mark, so a per-token line is not a return. The joint figure needs the position valued at both ends of the day, and on the day it is created there is nothing to read at the start. The ledger answers instead: a position whose members were all created inside the day was worth nothing when the day began, on both lines. It is the mirror of the closing rule (a member whose series ended is worth nothing at the close), and it has the same discipline — it reports or it stays silent, and it never turns a day the ledger can state into one that reports zero.
It is a valuation rule and not a trigger, and the scope of that is worth stating in three parts. Being created inside the day never on its own makes a position report jointly: a position whose pool did not re-mix reports per token, and so does one whose settlement landed inside the same day. A position only partly created inside the day keeps the conservative reading. And a position created AND closed inside one day reports per token too, because the shares a joint figure is split by are read off the daily values and there are none at either end of that day — the same reason a closing day whose shares cannot be established is left per token. In every one of those cases the day's own total is still right; what the rule adds is the split, not the sum.
The holding's at-entry basis and its vintage still read the venue's creation fact. Whether a movement opens a holding — the venue's own creation event where it publishes one, otherwise the movement's balance equalling its amount — decides whether the at-entry basis (M18) is read off that movement or reconstructed from the first snapshot the holding appears in, and what the holding's "since" date is. On the legs whose two quantity columns disagree, the old answer was a reconstruction dated the snapshot; the new one is the entry itself, dated the mint. The new answer is the right one — a reading beats a reconstruction, and the money went to work at the mint — and it moves the position row's at-entry column and the left-hand side of its secondary-market dislocation P&L.
An UNADJUDICATED withheld window is a barrier, and nothing books across one. Declining to publish a figure for a window is only half of honouring the reason it was declined; the other half is that no later window may reach back over it. A window that begins before a withheld one and ends after it has, at its two ends, valuations that already contain everything that happened in between, so the subtraction would hand the whole unadjudicated movement back as return in a single lump at the window that recovered, with no withholding record bounding it. That is the shape of an error the reconciliation identity cannot attribute to anything. So such a window ends the stretch a later bridge is allowed to span: coverage resumes from the first valuation on its far side, and the stretch that was cut off is recorded as withheld with the equity that was never published, once per stretch, at the point coverage was first lost.
The rule is about the windows nobody could adjudicate, and there are exactly two that a later window may reach over. The barrier applies to W3, W4, W7, W8 and W11 — the withholds that say something about the window itself cannot be settled: a seizure missing a side, a composite position that will not reconcile, a coverage scope that did not certify, a movement whose pairing is ambiguous, a holding whose quantity the reading audit had to correct. W1 (the position was held and the snapshot is missing) and the endpoint case of W2 (the snapshot is there and one price is missing) are different in kind: the position's occupancy is ledger truth, its quantity is known, and every movement inside the window is priced or the window is withheld for that instead. What is missing is a single valuation, and the two readings either side of the dark stretch settle it exactly. So those two DEFER rather than drop: the line books nothing while it is dark and books the whole stretch at the next reading it can be valued at.
And the rate follows the line, which takes one more rule. A time-weighted rate divides each period's figure by the book's value at the start of that period, and a book value cannot include a holding the mark in question could not price. So on every day whose STARTING reading is the one that is missing — the dark days, and the day the price returns, which is the day the whole deferred move is booked — the holding's last known value in that mark is added back to the denominator. Without it the largest figure of the stretch is divided by a book the holding had dropped out of; on a financed position it is worse than that, because the borrowing stays in the mark when the collateral leaves it, the book's value on those days is negative, and a period with no positive starting value is left out of the linked return altogether. On the position below that is the difference between a published 2.57% and 3.11%, on a line that rose $15,388.88 either way. The day that merely ENDS on a missing price is not one of them: its starting reading is priced, the holding is already in the denominator, and adding it again would count the same money twice. Reading a missing price as a barrier is what deleted $15,388.88 of real return from one portfolio when a token's price feed went quiet for four days in July 2026 — the line simply never got it back. The incident, the measurement and the rule that replaced it are in the mark-gap bridge plan.
Four things are deliberately not withholds, because the engine booked correctly and value nevertheless passed outside what is modelled: a derived-evidence leg (no discrete receipts exist, so it books 0 by construction; native ETH was the one such leg until issue #966 gave it receipts, and none is left), a leg the book excludes, a rotation whose wrapper is not modelled, and an exit whose proceeds left the covered perimeter. They are reported as coverage notes, and keeping them out of the withhold list is what stops the reconciler chasing correct arithmetic forever.
The index ratio is a diagnostic and never an input. Attribution from a venue's compounding index is removed from the engine entirely. What survives is an oracle: over an interval with no flows in it, the index reading and the law must agree to floating-point epsilon, and a disagreement raises a named alarm because it means a valuation is wrong. Nothing the oracle computes reaches a published number, and the test suite proves that mechanically by running the same input with the oracle switched off and comparing every booked figure.
One statement worth making explicitly, because it is a choice and not an inheritance. The accrual line keeps booking an exit's realized residual as accrual. On a closing receipt the leg books what was actually received minus what it was last marked at, on both lines, by the law and with no exit branch anywhere. On a leg whose redemption mark is its settlement value the residual is zero by construction and the question never arises; it is visible only where the two genuinely differ, and there the accrual line reports it rather than routing it to total return, because routing it would require exactly the branch this engine deletes.
M30 — what replaced the magnitude bands, and where a series now begins and ends
In one sentence: a position's history is cut into periods by the ledger's record of when it was held, where it used to be cut by whether the numbers on either side of a hole added up inside a tolerance.
That sentence replaces nine constants. The published behaviour it changes is what the product means by a period. A position that is bought, sold, and bought again is now two separate holdings with two entry dates, two vintages and two sets of sub-periods in the time-weighted return, whether the round trip took four months or four minutes — because the record says the balance reached zero and rose again, which is the definition of having sold. A position whose balance never reached zero is ONE holding even where the reader lost sight of it for a week, and the chart bridges that week rather than treating the far side as a new position. Under the old rule both of those questions were answered by comparing sizes: a move large enough looked like a sale, and a sale small enough looked like a quiet week. Both mistakes are visible on the live product today, and both are what the four zero-value anchor points on the portfolio chart came from.
Three consequences worth stating because a reader can see them:
- A pass-through never opens a holding. A position that arrives and leaves within a single transaction — a router hop on the way to somewhere else — is not recorded as having been held, so it starts no period, earns no entry date, and does not split the holding that follows it.
- A holding that opens and closes between two readings still books what it made. Its opening value is zero as a matter of record rather than a number that has to be read, so nothing is lost to a window the reader could not see into.
- A collateral row that simply failed to read no longer changes what a position IS. A financed position whose collateral is missing from a reading used to be re-read as an unsecured borrowing after two consecutive misses; it now keeps its identity for as long as the record says the collateral was there, and loses it the moment the record says it was withdrawn. Same reading, opposite answer, decided by evidence. The one case still outside this rule is a collateral position the reader never saw once in the whole window shown: there is nothing on the chart to carry across the gap, so the borrowing beside it still reads as unsecured. That limit is unchanged from the current behaviour.
Two mechanisms retired into diagnostics rather than deleted. The compounding-index reading is one (M29). The other is the recovered rate a position's own history implies: it is computed after the fact, reported to the reconciliation report, and no published number depends on it — which the test suite proves by recomputing every figure with it switched off.
A composite position is reconciled as a position. A Fluid smart pool re-mixes its two tokens between the transaction and the reading, so each token's leg is left holding a different share of the pair than its own receipt named. That is now detected at every boundary rather than only at a position's birth, and detected by comparing what the record says the leg holds against what the reading reports rather than by a size band; where the pair reconciles jointly, the joint figure is split across its legs by value, and where it does not, the position books nothing and says so. The measured case is a June 2026 restructure where 1,855.29 of realized P&L was withheld from the chart because each token's leg missed the band by a few percent while the pair as a whole reconciled to a tenth of one.
Returns as a RATE are withheld over an impairment. When a venue socialises a bad-debt deficit across its suppliers, the balance is written down by something the holder neither chose nor could avoid. The cumulative figure still shows it — it is a real loss and it is on the chart — but every headline percentage the write-down enters reads as a dash, because dividing a write-down of principal by the capital that was deployed publishes a rate of return for a period that had no return. That withholding covers the simple return, the time-weighted return and the annualised figure alike, and it is a property of the whole series shown, not of the six-hour window the write-down landed in: a percentage that excluded the loss while the chart beside it included it would be the same misstatement in a smaller font. A holding whose displayed history contains an impairment therefore shows its money figures and dashes its percentages, for as long as that history is in view. Two of the three percentages this covered (the book-level simple return and the book-level time-weighted return) have since been removed altogether, M8b; the withholding stands unchanged on the annualised figure, which is the one still published.
M31 — what counts as the reader's money, and what a rate is measured against
Every published figure below rests on one question nobody had ever answered in one place: how much money did the reader actually put in? The contributions line on the portfolio header asks it. The time-weighted return re-asks it at the start of every six-hour interval, and every annualised rate is that return reduced. A book-level simple return divided by it too, until that metric was removed (M8b). Several surfaces, several derivations, and the gaps between them are not rounding — they are the two defects below, wrong in opposite directions.
The rebuilt engine answers it once, from the movement's own label.
Every movement is one of twelve kinds, and the kind decides everything. Six of them are capital: a deposit, a withdrawal, a borrow, a repay, and a transfer in either direction. The other six are not: a liquidation seizure, a debt write-off, a reward, a cost, and the two halves of a move between the reader's own positions. A kind the table has not been taught cannot be classified at all — it stops the build rather than being handed a default — which is the mechanism that keeps this from drifting back apart.
Two more records sit beside them, and neither is a movement anybody made. The reading audit (Data pipeline) writes an opening, which states a holding older than the ledger's first movement, and an adjustment, which brings the ledger to a reading whose difference nothing on chain explained. Neither is capital at any scope, neither is a contribution or a withdrawal on any surface, and neither is ever booked as earned: both sit in the flow set of both return lines, and the stretch that ends at an adjustment is not measured (W11).
Capital nets across a whole transaction, in dollars. A leveraged position is opened in one transaction: money in, a loan drawn, the proceeds swapped, the whole lot supplied. Netting those movements one book at a time cannot cancel a swap that crosses two books, so the dollar book reports the gross supply as a contribution. Measured on a real transaction: 21,544.59 USDC in and 0.27029424 cbBTC out, against 4,534.69 USDC of money the reader actually committed — the contributions line reads 4.75x the truth. Netting the same movements in dollars over the transaction returns the 4,534.69. Two decisions minutes apart stay two contributions; two legs of one loop are one. The per-book netting the performance chart uses is untouched: this is a second, additive scope, and it is for the capital figure alone. Where a movement inside a transaction cannot be priced, the transaction's contribution reads as unknown rather than as the priced half of it, which would report exactly the gross figure the netting exists to remove.
Scope is always stated, never assumed. A transfer between two wallets of one account is money leaving that wallet's curve, so at wallet scope it is capital. It never left the account, so at account scope it is not, and counting it would book a withdrawal and a matching deposit the reader never made (measured at $5,763.98). The same stored movement therefore answers differently depending on who is asking, and one tracked wallet on the live product belongs to two different accounts at once, so no single answer could be right for both. Address-poisoning dust is out of the capital line at every scope — 20% of all inbound wallet movements are that.
The percentage is measured on capital, on both sides of the division. This is the second defect, and it runs the other way. A reward arrives; the return line correctly books nothing for it, because rewards are outside the published return by documented policy (M8). The value stays in the book. Every later interval then divides a real yield by an equity base containing money the return line refused to credit, so a wallet that earns rewards reports a lower time-weighted return than an identical wallet that earns none. On a $25 reward against a $100 position the reported rate falls from 9.00% to 7.83% for identical economics.
The fix is one rule with two halves, and neither works alone. Value the reader neither contributed nor earned is treated as a sleeve of the holding it sits in — a share of it — and that share is taken out of the percentage on both sides: its value leaves the base, and the profit or loss it makes afterwards leaves the yield the base is divided into. Taking only the first half would be the larger error in the opposite direction, because a reward token is an ordinary holding once it arrives: what it does next has no movement to net against, so the engine books it as return. A reward-only book that lost 40% would publish a flat 0.00%, and one that gained 20% would publish +400%.
With both halves the property holds rather than being approximated: two wallets holding the same capital and earning the same yield report the same percentage, whatever the reward token does after it lands. Because the sleeve is a share and not a frozen amount, it revalues with the holding, it is diluted when the reader adds their own money to the same holding, and it takes its proportional share out with any part of the holding that is sold.
It is exact wherever both ends of a six-hour interval are readings of the position itself, which is the ordinary case and every case on this page. It is close rather than exact in one named place: where the position could not be read for a stretch, one figure covers the whole stretch and there is no reading inside it to divide the sleeve's part of it from the reader's, so the split falls back to the composition at the stretch's end. The same applies where a movement arrives without the balances that say what the holding was made of. Both are properties of the missing reading rather than of the rule, both are recorded as coverage notes, and neither can arise on a position the pipeline reads normally.
The share is what the balance is made of, not what the reward was worth on the day. A reward is paid at one moment and the chart reads the position on a six-hour grid, so the two almost never sit on the same block, and treating the payment's own dollar value as the amount to hold out would leave everything the token did in between inside the reader's own return. The engine takes the split from the balances the movement itself records, so a reward claimed at $50 and worth $500 by the time a range opens is held out at $500, and one that moved 10% in the hour it arrived moves the sleeve and not the rate. Without that, the same wallet publishes 9.00%, 14.00% or 3.71% depending only on what its reward token did between the claim and the next reading, and a claim made a year before the range opens leaves the whole of its appreciation inside the base for the entire window.
It holds while the reward is held. Selling one is a decision about what to own, and what comes back is the reader's own money like any other holding, so from the sale onwards it is inside the percentage on both sides. A wallet publishing 9.00% while it holds a $50 reward against a $100 position publishes 6.70% once that reward is sold for a stablecoin, on the same underlying performance: the yield is unchanged and the capital it is measured against is $50 larger. That is the same rule as everywhere else on this page rather than an exception to it, and it is stated because the two figures are minutes apart on the same screen.
The money figures are untouched. Cumulative yield, total return and the chart carry every dollar the book made, the reward's own gain included, because the reader did make it. It is the rate that declines to credit it, since the money it was earned on is not in the rate's denominator either. Where a book holds nothing but value of this kind, there is no capital for a percentage to be about and the figure reads as a dash rather than as zero.
Three consequences of that rule that are choices rather than arithmetic, stated so they read as decisions:
- A gas payment made out of a tracked token is added back, so the base is the capital the reader deployed rather than what is left after fees. That is the same convention as the published return, which is gross of gas by documented policy.
- A liquidation still shrinks the base. A seizure is nobody's withdrawal, but the equity it took is genuinely gone, and the interest the surviving position earns is earned on what survived. Adding it back would divide a liquidated book's real interest by capital it no longer has and publish roughly half the rate it actually earns.
- A trade leaves the base where it found it. An internal movement is ordinarily value arriving from something the product does not model — a wrapper with no position of its own, a redemption queue — so it is held out of the base, which is what makes a round trip through one such wrapper leave the base where it started. A trade between two covered holdings is the opposite shape: both sides are positions the product reads, and the money that arrived is the money that left. Applying the ordinary rule would take it out twice and leave a wallet that traded with no capital for its rate to be measured against at all.
- A reward that is later sold or sent away stops being subtracted on the same reading that removes it from the book value, and one that is only partly sold keeps its proportion of what is left. Where several movements land in the same six-hour bucket they are applied in the order they happened on chain, which is what separates the two shapes that look alike and are not: a reward claimed and then part-sold leaves in the holding's own proportion, while a withdrawal taken before a reward is paid was entirely the reader's own money and leaves the whole reward standing. Applying a bucket's movements as a batch has to pick one of those answers for both, and gets the other one wrong by up to a twentieth of the published rate.
- The same clock governs the bucket's own return, not just its balances. What a position made before a movement landed is split at the composition that stood before it, and what it made afterwards at the composition that stood after, so money added part-way through a bucket does not reach back over what the bucket had already earned and a reward sold part-way through keeps the gain it made while it was held. Attributing the whole bucket at either end's composition moves the published rate whenever a bucket both earns and takes a movement: measured at 5.84% published as 8.88% on a wallet token topped up mid-bucket, 5.00% as 9.55% on a supply position paid an in-kind incentive and then added to, and 9.00% as 14.29% on a reward sold in full inside one bucket. All three read high, and the last one arrives as a step: on that shape, selling all but a ten-thousandth of the reward and selling the last of it would differ by 529bp.
M32 — the basis a holding entered at, and the identity that replaces M18's doctrinal note
M18's question does not change: the basis a position entered at, so today's gap can be read as a move rather than as a level. Nor does its formula. What changes is four things about what goes into it, one thing about what a holding is, and the doctrinal note at the end of M18, which under the rebuilt engine describes an engine that no longer exists.
A reward is part of the basis, and a gas payment is too. This is the cell that reads wrong at first. Neither is the reader's money, and the instinct is that what is not a contribution cannot be an entry either. The entered basis is not asking about money in: it is asking what the holding is made of and what the market charged for each piece of it. A reward paid in kind really did arrive at a price, so it changed the answer. Under the published engine a reward and a cost contribute nothing to the entry basis at all, because the function that signs a movement was only ever taught seven of the twelve kinds and quietly answers zero for the other five. A seizure, by contrast, stays out: a liquidation is not a purchase.
A rotation carries the entry across instead of re-striking it. When a holding moves from one wrapper to another, the arriving side inherits the basis and the date of the side that left, pro-rated by its share of what came back. The unit is the whole episode, never a pair of matched movements: the live case is six movements in six transactions over 39 days, two positions merging into one, with no tracked leg for either wrapper for 26 of those days. If the arriving side booked its own gap instead, the holding's entry would silently become the price on the day of the move and the original trade would be gone. Two consequences worth stating because a reader will meet them:
- A merge reports the OLDEST of its sources' dates. Two holdings rotated into one report the earlier start, so the age of a blended holding is an upper bound. The alternative is a weighted average, which produces a date on which nothing happened and restarts the clock a little on every top-up; the whole point of carrying the date is that money that never stopped working never looks new.
- A rotation into something the reader already holds is a top-up. The basis carries; the date does not move. The date only ever gets older across a move, never newer.
- A hop taken inside one episode changes nothing the reader is shown. A holding can leave for one wrapper, come back, and leave again for a second before the run is over, and the record keeps the whole run together as one episode rather than as one per hop. The live case is exactly that: GHO into a staking wrapper, back, into a savings wrapper, back, across 39 days. The middle hop moved no money into or out of the portfolio, so what it sends back is treated as a hand-back rather than as a fresh entry: the holding at the end of the run reports the same entry basis, and the same start date, as it would have had the money gone out and come back once. Read the other way the episode has no answer at all — the entry would be defined in terms of itself — and the reported entry basis of every position on the wallet degrades to a dash. The same rule holds where a holding DIVIDES, part on to another wrapper and part back into the run it came from: each side carries the entry attached to the amount it actually holds, and the answer does not depend on which of the two moves the record shows first.
- The date crosses even where the entry price cannot be read. A movement that arrived without a price, or one that happened before tracking began on the side it left, still moved the holding, so the arriving side still reports the original start date rather than the day of the move. What it does not do is invent the basis. Where the entry a rotation should have carried cannot be read, the arriving holding's entry is reported as partial rather than as a finished number, and the identity below declines that holding instead of confirming a figure it has no way of seeing the gap in. Zero is never used to stand in for an amount nobody could read, on either side of a move.
A holding that was sold and bought back has two entry bases, not one. The published engine sums a position's whole history into a single figure, so after a round trip the second purchase's entry is reported net of the first sale's realized dislocation. The rebuilt engine cuts the history where the record says the balance reached zero (M30), and each holding carries its own entry and its own date. Within one holding the old behaviour is unchanged and is deliberate: a partial sale still nets into "At entry", which is what lets a partial unwind realize the dislocation on the slice that left.
A correction enters like a top-up, and an opening is where a holding starts (ledger-first R5b, R7). The reading audit writes two records nobody made (M31), and both carry the two marks of the reading that wrote them: the market price newest at or before that reading's block time, and the redemption value read at that block. An adjustment on a holding whose two marks differ (a yield-bearing asset with both a market and a redemption value) therefore enters at that reading's gap, exactly as a real top-up of the same size at the same reading would, blended into the entry by size like any other purchase; a real top-up after it blends in the same way, and the identity below holds across both, because the two return lines take the adjustment as a flow at the same two marks. A holding whose two marks are equal has no gap, so a correction to one moves no entry and there is nothing to decide. An opening, which states a holding older than the ledger's first movement, is where that holding's entry is anchored: the gap at the opening's own reading, not at whatever reading was stored first. The two are one reading wherever both exist, since an opening is written at the first reading that finds a holding whatever it is worth (a balance under a cent included since PR #963); where they are not (an earlier reading of the holding that no audit opened), the entry is the holding the opening states. The 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 (§C5), the opening states no entry on that line either, and the entry reads as the dash the reading gives, never a gap struck from the priced side alone (PR #963). What a holding held before its first record is only ever a record's to state, an opening or an earlier reading of it, and never worked back out of the first movement's own balance columns. Where no record states it, a movement out of that holding carries no entry basis at all: it nets into nothing, and in particular never into the entry of the NEXT holding on the same asset, which enters clean at its own marks.
A term instrument still enters at zero, and so does any wrapper whose two marks are equal. Both survive unchanged. A PT's redemption mark is already anchored at the yield the holder locked in, so its current gap already is its move since entry; the spread a buyer crosses on the way in is a real transaction cost and appears on the total-return line, where it belongs, and never as an entry basis. Zero regardless of whether the purchase itself is in the record, which is the one place this could have gone wrong and is the shape the only tracked term instrument has: the answer follows from how the instrument is marked, not from any reading, so a term instrument bought before tracking began enters at zero the same way, rather than at whatever gap the first reading happened to show or at a dash. A wrapper whose two marks are equal at every movement nets to zero without anything here knowing why, which is the property that made the retired pinned class (M19) need no branch of its own — and it extends to the new movement types for free: a reward on such a wrapper, a rotation of one and a liquidation of one are all covered by the same nothing. The class itself is gone (#810 Y2 made sUSDS market-measured), so today the property has no members; it is the shape of the rule that survives, not a live exemption.
The Aave caveat is retired, not weakened. M18 warns that an aToken transfer's value bundles interest accrued since the holder last touched the position, so a per-movement basis on Aave and SparkLend is approximate at the accrued-interest level. Under the rebuilt engine the movement's amount comes from the venue's own event and is the true amount, and both marks are two prices applied to that same amount. The approximation the caveat apologised for is gone.
And the doctrinal note is replaced by an identity that is checked, not asserted. M18 ends by saying the entry basis is not the difference between the two published return lines, because that only held for some kinds of position and would be wrong on exactly the leveraged carries that matter most. That was true of an engine with a separate attribution path per kind of position. There is one path now, so subtracting the two lines gives, for a holding measured from the day it was put on:
total return since entry - accrual since entry = today's basis - the basis at entryThe two sides are computed from different data by different code and must agree to the cent. That is a much stronger guarantee than either side alone, so it is a test the build runs rather than a claim this page makes. Two named terms sit alongside it and both are reported rather than absorbed, because a reconciliation that is approximately true is where a real defect hides: a liquidation is netted on one line and not the other, and a rotation carries a basis rather than striking a new one. Where a holding has neither, the identity is the plain sentence above.
Where it does not apply, and why that is not a failure. The identity is measured from the day a holding was put on, so it needs that day to be in evidence. Where the engine declined to book a figure it could not stand behind, where a movement arrived without a price, or where a holding's start is not in evidence at all, the check reports that it does not apply and says which, rather than reporting a disagreement. Two more cases are on that list for the same reason, and both are the engine working correctly rather than failing: a paired position whose two sides are measured jointly and then split between them, because the identity is a per-side statement and the split is not one; and a holding the book deliberately keeps out of every return line, which earns nothing on paper and so has nothing to reconcile; and a holding built out of a move whose entry could not be read, where the figure shown is a floor rather than an answer and the check would otherwise confirm it. An entry basis with no evidence behind it is shown as a dash and never as zero: zero is a claim that the holding entered at par, which is a different statement from not knowing. The exception is the one case where zero is known rather than assumed, the term instrument above.
And a dash says which absence it is. Where an entry basis cannot be stated at all the cell carries a hover note, because a bare empty cell reads as a figure that happens to be missing rather than as one the engine declines to invent. Two sentences, because the holder acts on the two differently: a holding whose opening was never observed will never have an entry to show, while a figure that could not be worked out from movements that ARE on the record is a state that clears. The two are told apart by a flag of their own rather than by the absence of a figure beside a partial marker, which a paired position also produces whenever one of its two sides was never observed opening. Neither is described as partial: "partial" qualifies a number that is shown and is a floor, and there is no number here to qualify.
M33 — the valuation policy: one registry decides, one function values
Every covered asset has exactly one valuation method and at most one price source, both stated on a registry row, and no code list decides either. That sentence is the whole policy (#810, settled 2026-09-06/08). What follows is what it means per family, and where each part is enforced.
Before it, the same asset could be described in eight places that disagreed. cbETH carried a published asset profile and no wallet-token row for four months, so the profile linked to an asset a holder's wallet was never swept for. USD3 had a row and never entered the price mirror. Fifteen dollar stablecoins were marked at exactly $1 for no stated reason while USDe was marked off its quote. Which method a token got had been decided by what broke when it was added.
The funds (R1–R3)
R1 One fund registry. The funds listed on the multi-strategy tab and the money-market tab ARE the portfolio's coverage of funds. The reader universe, the product category (Multi-strategy funds / Money market funds) and the pricing method all come off those rows. There is no second list. Every listed fund is read, and by exactly one reader. A fund whose share rate is an ERC-4626
convertToAssetsis read as a vault position, denominated in the fund's underlying; the four whose rate is not (a Veda Accountant, two Mellow oracle reports, and tETH's wstETH-denominated conversion) are plain ERC-20 share tokens whose deposits are mints and whose withdrawals are burns, so the wallet book reads them and their ordinary transfers carry them. Which reader found a fund is not a fact about the fund: the category comes off the fund registry either way, so both render under the same heading.Known limitation, on the two Lido Earn funds. Those vaults process a deposit in a queue: the shares are priced and reserved by the vault before they are minted to the depositor, and in that window the holding exists as a claimable balance with no token movement of any kind to observe. So a deposit sitting in the claim queue appears when it is claimed, not when it was made — the position is understated until then, and its entry is dated at the claim. Every other fund mints straight to the depositor and is unaffected. It is a reporting delay on a deposit the holder has already made, never a value that goes missing once claimed (issue #824).
R2 Coverage is "ever listed". A fund that drops below the listing floor leaves the TAB and stays in coverage for every wallet that holds it. Otherwise a delisting would read as a withdrawal that never happened.
R3 A fund is never priced off a quote.
value(share) = shareRate(block) × value(underlying), chained down through wrappers until a traded asset is reached (iETHv2 → stETH → wstETH → WETH). On the total-return line the underlying is at its market price; on the accrual line at its redemption value, so the gap between the two lines for a fund is exactly the underlying's dislocation and nothing else. Every listed fund carries a block-pinned share-rate getter, read at the leg's own block in both the history and the live mode; a stored six-hourly series is not acceptable for a fund, and a fund whose getter cannot be read is skipped (M9) rather than valued off one. A fund share gets no price bar, no secondary-market fact and no divergence badge, because it does not trade.
The idle assets and the mirror (R4, R6)
- R4 Idle assets are marked, none pinned. Every idle asset that trades is priced from a standing hourly feed; the ETH sentinel is the book's own unit and has no series, which is why the registry carries 53 idle rows and 52 standing feeds (32 and 31 when #810 shipped, plus the three Pendle PT payout assets #811 added and the eighteen the 2026-09-16 coverage rule brought in). The fifteen par pins and the whole pinned basis class are gone (M19). ETH stays the ETH book's unit (identity); WETH is identity in the ETH book and tape-marked for its dollar level.
- Fifty of the fifty-two feeds are Dune's tape and two are not (2026-09-17, #907). The rule is that an idle asset is MARKED, not that Dune is the one marking it: BTC.b's Dune tape stopped on 2026-08-29 while the token went on trading, and USDai's had only just started, so both are marked off DefiLlama's aggregate instead — a third feed kind that writes the same hourly table through the same writer. The aggregate is admissible for an IDLE or no-base row and never for an accruing row that has a base: a blended multi-venue quote is fine for a level and too noisy to carry a basis line, which is the measurement
docs/plans/dune-price-mirror-plan.mdrecords and the reason the mirror was built on Dune's tape in the first place. - R6 The mirror is hardened: a per-token sync cursor that retries the window it lost, auto-backfill from the 2026-01-01 history floor when a row declares a feed, an accept-then-retract spike gate (a print that departs from BOTH neighbours by more than 5% while those neighbours agree within 1% is rejected and every read skips it), a paging alert on any standing feed with no accepted bar in 12h, and per-hour volume and source stored so a carried quote is identifiable rather than inferred. The full mechanism is in Data pipeline. Every one of those applies whichever feed fills a row: the gate judges a bar wherever it came from, and a feed going dark is a feed going dark whoever was supposed to be filling it. The auto-backfill leg is the one with a COST attached, and it is worth stating where the rule is: it fires on the first standing tick after a row declaring a feed reaches the code — the compiled seed, so before that row's migration is applied, EXCEPT where the row already exists in the database, in which case the database's
feedwins until the migration lands. A Dune-fed row buys the floor-to-now span in three 90-day executions, every token needing one riding the same CSV; an aggregate-fed row buys it in about 38 keyless HTTP requests, a week of hours each, paced a quarter-second apart. Adding a fed row is therefore a cost decision taken at the moment the row is written, not at the moment someone chooses to run a backfill.
The rebasing tokens (R5)
stETH and eETH are variable-rate assets accounted in SHARES — quantity, receipts and both marks. M5.3 states it in full.
The yield-bearing tokens (Y1–Y7)
- Y1 One row decides everything for a tracked token: whether wallets are swept for it, its product category, its rate source, its price source and its asset-profile link.
- Y2
valuation ∈ {market, composed, derived, identity}. There is nopinned. - Y3 One declared price source per token, for its WHOLE series — the Dune tape where trading is dense, a routed saved query where one pool IS the market (sUSDe), DefiLlama's aggregate where Dune's coverage does not reach an asset that still trades (BTC.b and USDai, #907), composition where no market exists at size ($1M loses percent rather than basis points). A redemption-priced token MAY still declare a feed: its bars are then synced so a market that appears leaves a record, and never used for its marks. The three feeds PARTITION the rows, because bars are written once and never overwritten: a token two writers both filled would have its provenance decided by whichever execution landed first, and frozen there for ever.
- Y4 Changing a source is a registry edit plus a re-mark, never a per-bar switch. The measurement series measures, the six-hourly job proposes, a person edits the row, the history is reloaded from the new source and the stored marks are recomputed. Code never picks a source per bar (M26).
- Y6 The rate source is a block-pinned getter by default, read at the leg's own block in BOTH modes. A stored six-hourly series is allowed only where the rate is not readable on Ethereum at a block, and then it is declared: sUSDai, whose ERC-4626 accounting lives on the Arbitrum hub, is the only one.
- Y7 Category is what the asset IS, not how it is valued. Every profiled token is a variable-rate asset, sGHO included: composition is a valuation method, not a category.
Where each part is enforced
| the claim | what holds it up |
|---|---|
| one method, at most one source, per asset | mirror-coverage.test.ts, against the registry rather than against static arrays |
| a composed or fund row's chain ends at a tracked identity or market row | the same file, walked recursively — so listing a row cannot leave an untracked bottom |
| the branch an asset takes | unit-prices.ts, the one composition function (M5.2) |
| a fund's rate is block-pinned | rate-getters.ts reads every declared getter kind at the block; an unknown kind is a logged skip (M9), never a silent 1 |
| a profiled token has a wallet-token row; a wallet-tracked variable-rate row has a rate source | mirror-coverage.test.ts |
A method change is a restatement, and the restatement is part of the release. Rows written before the boundary carry the old method, so the first interval spanning it books the difference as return, once, for every wallet holding the asset. A full re-derive of every tracked wallet is what removes that step, and it runs with the release rather than after it — the same rule M26 states for any method change.
Where each metric is read
| Metric | Reader | Page |
|---|---|---|
| Trailing APY (any venue) | getTrailingApy (apy.ts) | all |
| Portfolio total return + accrual, realized APY (over the linked TWR, which is engine-internal and not published, M8b), marks, wedge | src/lib/portfolio/v2/engine.ts (the one law, both series in one pass) + v2/attribution.ts (buildCurve) + v2/segments.ts (computeViewSegments) over portfolio_position_snapshots + portfolio_flow_events_v2, with pnl.ts keeping the shared primitives (legIntervalYield, linkTwr, annualizeTwr, classifyLegsAtTs, assetWedge); crossed onto the wire once, in historyPointsFromBuckets | Portfolio (/portfolio) |
| Entry basis / dislocation P&L (M32) | deriveEnteredBasis (src/lib/portfolio/v2/entry-basis.ts) over the wallet's whole leg corpus at once — a rotation's two halves land on two legs, so a per-book derivation would drop the half out of view — with the row carrying its CURRENT holding's span rather than the leg's whole history (M30) | Portfolio (/portfolio), expanded carry detail |
| Carry leverage / LTV / distance to liquidation | carryRisk (signed-in-model.ts) over the leg values in the MARKET mark, plus the pair's on-chain borrow cap + liquidation threshold from GET /api/portfolio/risk (risk-params.ts). Account-scoped on Aave / SparkLend, position-scoped on Fluid / Morpho | Portfolio (/portfolio), carry row's LTV cell + the All view's financed positions |
| Net APY at a simulated leverage | carryRates / carrySpread / netApyAtLeverage (signed-in-model.ts): netApyNow + (collateralApy − borrowApy) × (L − Lnow), the anchored form of collateralApy × L − borrowApy × (L − 1). Holds both legs' quoted rates at today's marks | Portfolio (/portfolio), expanded carry's leverage simulator |
| Asset 30d / 1M / YTD / 1Y | apy30dFromRates + daily refresher | Asset profiles (/asset-profiles) |
Per-asset yield history (trailing-24h APY, one point per UTC day, no benchmark overlay; publish gaps repainted by apy-gap.ts, axis fitted to the bulk by yield-chart-scale.ts), drawn beside the redemption rate (share_rate from the same daily snapshot, units of the underlying per token) on a second axis; each legend entry hides or shows its line, and the last visible line stays | getYieldApyHistoriesByTicker (yield-history.ts) → AssetYieldChart in the row expansion | Asset profiles (/asset-profiles) |
| Net carry / leg APYs | getCarryRow, getCarryHistory (carries-table.ts) | Carry trades (/carries) |
| Carry vol / vol-adj / worst week | carryWindowStats, carryRiskStats | Carry trades |
| Max-lev carry | page.tsx; sim grownLeveragedPosition | Carry trades |
| $1 cum-return curve | CarryChart.tsx plotData | Carry trades |
| Borrowable / capacity | getVaultCapacity (vault-capacity.ts) | Carry trades |
| Basis / peg | getCollateralBasis, getDebtBasis (basis.ts) | Carry trades |
| Oracle transparency | getOracleReport (oracles.ts) | Carry trades |
| Fund APY over a 24h / 7d / 30d window | windowApy (strategies-table.ts) — realised share-rate ratio between the two window endpoints, annualised by actual elapsed time. On an oracle-reported fund the window contains a whole number of price updates, and the reading swings both ways. Zero updates reads a true 0% (routine on the short windows: earnETH's Mellow oracle can hold for ~27 days); two updates inside a 24h window reads about DOUBLE the fund's rate, two days of accrual annualised over one — 9 of Lido Earn USD's 661 stored 24h readings sit above 15% on a fund earning ~7%, each adjacent to a 0.00%. A second, smaller artifact rides the same mechanism on every window: the ratio spans the two REPORTS the endpoints resolve to while the annualisation divides by the SNAPSHOT span, and on a daily-report series those are not the same interval. Bound is ±(1 report interval / window length), so ±3.3% relative on 30d — measured 2026-08-20, the 30d cell printed 7.198% where the accrual actually took 30.926 days and annualises to 6.975%, +22.3bps of pure measurement artifact, oscillating in sign as the report clock drifts. The continuously accruing ERC-4626 funds on the same page carry none of it. null only when the window cannot be measured at all | Multi-Strategy Funds (/multi-strategy-funds) |
| Fund TVL | tvlNative (strategies-table.ts) — share supply x share rate, in the fund's own denomination; live on-chain read, falling back via recentSeriesTvl to the newest persisted total_supply x share_rate snapshot within 48h of the series' newest point. Past that ceiling it reads null rather than serving a size nobody has updated, since the same figure drives the Min TVL screen. "Share supply" is the vault's share COUNT, which is not always its ERC-20 totalSupply(): on a Mellow ShareManager, shares priced at an oracle report but not yet claimed by their depositor are already a claim on the assets and already inside the reported NAV while remaining unminted, so Lido Earn USD is sized on totalShares() (26% of its shares sat unclaimed when it was wired) and the funds page passes the same selector to its live read that the refresher stored the history with, or the KPI and the chart under it would be two different measurements. Lido Earn ETH has the same split, running a time-varying 1.2-15% across its history rather than the stable figure it looks like at head, and stays on totalSupply until that restatement is made deliberately (#626) | Multi-Strategy Funds (/multi-strategy-funds) |
| Fund max drawdown | maxDrawdown (fund-stats.ts, re-exported from strategies-table.ts) — the deepest peak-to-trough fall in the published share value, as a positive fraction, over the snapshots creddit holds. Not a drawdown since the fund's inception: a loss taken before tracking began is invisible to it. The row's tooltip always states the tracked-from month, and the LABEL repeats it (Max drawdown · since Jan 2026) on any fund whose readings do not start in its launch month (in either direction, and whenever the launch month is unknown), so the figure is never read against the differently scoped track record beside it. Where the readings begin at launch the label stays bare, since the track record's own date already scopes it. A monotone share rate reads 0.00%, a real reading, not a missing one | Multi-Strategy Funds (/multi-strategy-funds) |
| Fund rate type | per-fund editorial in page.tsx / UsdVaultRows.tsx, out of a closed set (Variable, Fixed · published rate, Fixed · to maturity). The first row of the statistics tower, because it frames every return figure above it: a floating rate reports what the book earned, while a published rate is one the manager holds and funds the difference on (Fluid Lite USD, whose protocol reserve absorbs the gap). The trailing return columns stay the check on a published rate | Multi-Strategy Funds (/multi-strategy-funds) |
| Fund track record | months since the vault's mainnet inception, from the per-fund inceptionIso in page.tsx / UsdVaultRows.tsx — sourced from each manager's own documentation, deliberately NOT from the first snapshot (most funds were tracked from part-way through their life, so the snapshot date would understate the record). Where a manager publishes no inception date, the fallback is the vault's on-chain DEPLOYMENT block rather than a rounded month (Lido Earn USD, deployed in block 24,602,425 on 2026-03-07); it renders at month granularity either way, so the verifiable date costs nothing | Multi-Strategy Funds (/multi-strategy-funds) |
| Fund fees and redemption terms | per-fund editorial in src/app/multi-strategy-funds/page.tsx (and UsdVaultRows.tsx for yvUSD), verified against each manager's published documentation. Performance and management fees are already reflected in the share value, so the APY and return columns are net of them; the exit fee is not, since it is paid on the way out, and where a fund also charges an ENTRY fee (IPOR Liquity Carry is the first on this page that does, at 0.20% each way) that too sits outside the share value | Multi-Strategy Funds (/multi-strategy-funds) |
| Fund vault infrastructure | per-fund editorial in src/app/multi-strategy-funds/page.tsx (and UsdVaultRows.tsx for yvUSD), out of a closed set defined beside the type in FundBrief.tsx. The platform whose vault contracts hold the assets and enforce the mandate, which is a second counterparty the depositor takes on top of the manager: ether.fi Liquid ETH runs on Veda (Seven Seas is its strategist, a different role), both Lido Earn funds on Mellow (Veda co-curates one earnETH sub-fund; the infrastructure is still Mellow), and Fluid, YO, Treehouse, Yearn and IPOR each run their funds on vault contracts of their own, which reads Proprietary (IPOR Liquity Carry is an IPOR Fusion PlasmaVault of IPOR's own, so manager and stack are one house there). What was checked, and what was not. Each value is cross-checked against the pricing source the fund's own rate is read from, which is the contract-level evidence this repo actually holds on the question. That positively corroborates the two third-party calls: liquidETH is priced veda-accountant in token-yields.ts, off getRate() on a Veda BoringVault's Accountant, and both Lido Earn funds mellow-oracle, off getReport(asset) on a Mellow Oracle. It is weaker on the seven Proprietary calls, which are priced straight off the fund's own share token (erc4626 for iETHv2, fLiteUSD, yoETH, yoUSD and yvUSD; treehouse-teth for tETH, reading convertToAssets on Treehouse's own vault; IPOR Liquity Carry off its own Fusion PlasmaVault share, read in strategies-table.ts) with none of the platform accountant or oracle that Veda and Mellow each require. That rules those two platforms out; it would NOT distinguish a fund sitting on a plain ERC-4626 vault platform, so read Proprietary as "no third-party stack identified" rather than as a positive audit. It closes the statistics tower rather than joining the manager on the row's identity line because it is a term of the holding rather than a description of the fund. Typed as a discriminated union whose platform names are themselves a literal union rather than free strings, so naming a platform the file has not been taught is a type error (a named platform always carries a url; the proprietary case carries neither), and required on FundStats, so a new fund cannot ship without an answer and none is ever defaulted; a platform links out, Proprietary renders as plain text | Multi-Strategy Funds (/multi-strategy-funds) |
| Fund yield sources | a closed category vocabulary in FundBrief.tsx (Staking, Lending, Leveraged carry, Liquidity provision, Fixed rate, Basis & funding, Incentives). Categories, never the assets or venues a fund happens to hold: those rotate under an actively managed mandate, so a holdings list dates on the next rebalance and invites diligence on positions the manager is free to replace. A category is named at the level the fund's mandate FIXES, not at the level it rotates: a base asset the fund is defined by earns its category (iETHv2 holds staked ether, so Staking), while a collateral basket the manager reshuffles is tagged for the spread the fund earns on it rather than for each issuer's own trade (Fluid Lite USD reads Lending · Leveraged carry, and Basis & funding is reserved for a fund running the delta-neutral position itself). A category has to be something the mandate is built on rather than a position that happens to be open, which is also what earns an allocator every category its sub-funds run. Guarded by an e2e spec that walks both denominations | Multi-Strategy Funds (/multi-strategy-funds) |
| Fund exit liquidity, JIT depth, binding cap, notice period, exit shortfall | src/lib/data/money-market-fund-math.ts (pure, client-safe) via src/lib/data/money-market-funds.ts | Money Market Funds (/money-market-funds) |
| Money market fund realised APY over a 24h / 7d / 30d window | windowApy (strategies-table.ts), over token_yield_apy share rates | Money Market Funds (/money-market-funds) |
| Money market fund chart series and benchmark | getMoneyMarketFundHistory / getMoneyMarketBenchmark (money-market-funds.ts), served by GET /api/money-market-fund-history | Money Market Funds (/money-market-funds) |
| Fund benchmark spread (Vs SOFR / Vs wstETH) | fund APY minus the denomination's baseline over the same window, both via windowApy over a realised-return index (see "Benchmark spread column" above) | Multi-Strategy Funds (/multi-strategy-funds) |
| Per-fund chart spread (band + legend readout) | in StrategyChart.tsx: the CUMULATIVE difference between the fund's curve and its benchmark's over the window on screen, in basis points, filled between the two curves and stated as a signed figure in the legend row. The legend names its basis and its measured span on screen (spread · cumulative, 6 mo) with an explainer, because the row's Vs SOFR pill is the same signed-bps shape a few inches away and a different figure. The fill is split at every crossing so a window the fund spent partly behind is not washed in one colour, while the readout, a single measurement, takes its own sign. A different figure from the Vs SOFR column above, which is annualised over a trailing window: the readout has to measure the gap the band is drawn across, so it is read off the same two cumulative curves the legend prints. Measured in raw percent, so the LOG axis cannot move it, and withdrawn entirely when the benchmark line is hidden or the chart is switched to TVL | Multi-Strategy Funds (/multi-strategy-funds) |
| SOFR series / index | getSofrSeries, getSofrIndexRows (sofr.ts); the day-count restatement and the latest-published read are sofr-basis.ts, import-free so client components share them | Asset profiles, Multi-Strategy Funds, Repo lending, Home |
| Repo-market supply APY | getTrailingApy | Repo lending (/repo-lending) |
| Repo-market size (deposited / available) | stored per snapshot; Fluid's raw token amounts are divided by the asset's own decimals (fluidScale, money-market-rates.ts) since USDS and GHO are 18-decimal against USDC/USDT's 6 | Repo lending (/repo-lending) |
| Repo-market utilization / target | derived as the deployed share of deposits, (deposited − drawable cash) / deposited (utilizationOf, money-market-rates.ts); target read on-chain via getTargetUtilization (target-utilization.ts), suppressed on any Aave-family reserve whose rate curve is flat, on a Morpho market not running the AdaptiveCurve IRM, and on a Fluid token whose resolver layout is unrecognised. Fluid's pair is Liquidity-Layer-level for the borrowed token (per-token curve, aggregate utilization), and its target is the first kink | Repo lending (/repo-lending) |
| Underwritten capital (slices, coverage, overcollateralization, capacity) | market_collateral_exposure + market_risk_current via cap-exposure.ts → UnderwrittenCapital.tsx, under the freshness and vintage rules above | Repo lending (/repo-lending) |
| Vs SOFR | the market's trailing supply APY minus latestSofrAvg30dApy (sofr-basis.ts) — the published 30-day average restated onto the annual-effective basis, withheld past the staleness ceiling | Repo lending (/repo-lending), Home |
| Which venues qualify as repo markets | STATIC_MARKETS + the Morpho registry's repo track (money-market-rates.ts). A reserve that compensates no third-party supplier (100% reserve factor) and prices off a flat, governance-set curve rather than utilization is not one: Aave's GHO reserve is excluded on that ground (see above) | Repo lending (/repo-lending) |
See Data pipeline & refreshers for the cron cadences that fill these tables and Database & schema for the full schema. The per-vault oracle/liquidation editorial copy lives in src/lib/data/fluid-oracle-copy.ts (covered above).