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 |
| 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) |
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 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 / Sharpe 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. The target90d / funding90d fields keep the 0 coercion for the consumers that do arithmetic with them.
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 / Max-lev / Vol-adj columns therefore price the collateral leg at the CURRENT fixed rate (
targetFixedToday, across every trailing window) with a funding-leg-only vol haircut; 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 Momentum column (for a PT row it is the trajectory of the entry spread on offer,
carryMean7d − carryMean30dover the per-date "offered fixed rate minus that date's debt rate" series, so negative momentum means the enterable spread compressed, which can be an offered rate falling or funding rising; a real entry-timing signal, and note it does not restate the Carry cell), 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); the oracle panel copy carries that.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 APYcarry7d = target7d − funding7d (and the 24h/30d/90d analogues). The leg APYs are the composed trailing figures above. This is the per-unit (unlevered) spread.
The collapsed row's headline: a position opened TODAY
Every APY figure in the collapsed /carries row (Carry, Max-lev, Vol-adj) 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 Vol-adj 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 |
| Vol-adj APY | carry − 1σ(daily carry, 30d) | carry − 1σ(daily funding leg, 30d) |
| Momentum | carryMean7d − carryMean30d | unchanged (offered-quote series) |
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 (funding24h, 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. Vol-adj and Momentum are NOT windowed: they stay on the 30d headline / daily sample and never move with the switch.
The adjacent Liquidity cell is current borrowable entry headroom in USD (borrowableUsd). Its 100px bar is logarithmic across the working range:
fraction = clamp((log10(max(liquidityUsd, 1)) − 5) / 3.3, 0.03, 1)Ticks mark $1m, $10m and $100m. Values below $100k render red. A Min Liquidity filter (the $M field in the filter bar, entered in millions) keeps only rows whose borrowableUsd ≥ the threshold; a row with no liquidity reading drops out, and — unlike Carry / Max-lev — it is not windowed. Momentum remains carryMean7d − carryMean30d, but its row visual is a diverging centre-pinned line: the signed move grows left or right and fills one 42px half at 2.2 percentage points.
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.
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 / Vol-adj 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. Momentum, a historical statistic, keeps rendering; that asymmetry is deliberate. (This is also why the field exists at all instead of reusing target24h: combineLeg coerces a missing component to 0, so a stalled refresher would make target24h read 0 and the row would print a large negative carry instead of "no data".)
Max-leverage carry
maxLev = L · target − (L − 1) · funding, L = 1 / (1 − maxLTV)Implemented in src/app/carries/page.tsx (leverage = 1 / (1 - maxLtv); maxLev = leverage * row.target7d - (leverage - 1) * row.funding7d). 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 (Sharpe-like)
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.carrySharpe=carryMean / carryVol(unitless).
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 guards float-cancellation noise from a flat sample (e.g. a stablecoin parked at its rate kink) from producing a meaningless mean / 1e-17 Sharpe; null fields when the window has < 3 daily observations or vol is degenerate.
What the UI actually shows. The Carry/vol ratio was retired from the collapsed table. The row 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) dashes the cell rather than claiming a zero haircut. The raw carryVol30d and a Worst week path stat live in the expanded row's Carry stability block.
- 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 (grownLeveragedPosition in src/lib/sim/leveraged-position.ts) and the /api/sim/swap-cost route — single source of truth, so the green curve and the headline RETURN agree at every horizon.
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.
- Spread, in bps (home rate tape,
src/lib/data/home-metrics.ts): takes the latest populatedavg30d, converts to a fraction (/100), and printsround((bestSupply − sofrFrac) · 10000)bps vs SOFR. - Realised-return benchmark (multi-strategy-funds comparison 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. The index isnullbefore 2020-03 (when the NY Fed began publishing it); those rows are filtered out.
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 (getEmodeDebtUtilization, ≤2 days stale or omitted) — Aave's own utilization definition, the figure that drives the borrow rate and entry headroom. 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, read from market_risk_current.stable_liquidity_usd (src/lib/data/market-liquidity.ts). Liquidity is only tracked for the pooled stablecoin (USDC / USDT / USDS / GHO) markets, so WETH-debt carries use only the first two terms; 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).
Deposit assets with no lender rate (Aave's GHO reserve)
Aave's GHO reserve holds ~$135M and runs at ~74% utilization, but its supply APY is exactly 0% and always has been: its liquidityIndex has never moved off its 1.0 (1e27) starting value across every snapshot since 2025-05-21.
The cause is the reserve's 100% reserve factor (reserveFactor = 10000 bps, verified on-chain): every basis point borrowers pay is routed to the Aave DAO treasury, and none of it reaches depositors. Borrowers do pay (a flat 3.75% at the time of writing), so this is not an idle book, it is a book whose entire interest stream is directed elsewhere. A GHO holder earns through the savings vault sGHO instead, which is covered on Asset profiles.
This is a governance parameter, not an immutable property. Aave can lower that reserve factor at any time, at which point the reserve starts paying lenders. The market definition therefore only carries
expectNoLenderYield: true, andnoLenderYieldFor()confirms it against the data on every read: if any trailing window comes back non-zero, the flag is dropped, the real rate is shown, and the discrepancy is logged. A hardcoded flag would leave the page asserting "no lender rate" over a reserve that had started paying one, indefinitely and silently.
The row is kept rather than hidden, because a reader comparing where to lend GHO needs to see that this venue pays nothing, and because the collateral behind the borrowed book is still the credit question. The APY cell reads "No lender rate" with a pointer to sGHO instead of a bare 0.00% that would look like a stale or broken number beside the live rates. Two other surfaces follow the same flag: the rates chart omits the market from its APY view (a flat 0% line reads as a rate that collapsed, not one that never existed, though the market keeps its size and utilization series), and the get_money_market_rates agent tool returns null rather than 0 for its trailing APYs so the figure is never averaged or ranked against real rates.
Its target utilization is also suppressed, 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.
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 Aave's GHO) 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 Liquidity 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.
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. 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, soDEBT_LEGmaps these tonull(the key must still exist so an unmapped debt symbol surfaces in CI). - Pinned-basis-class legs: not charted (M19). A wrapper tagged
basisClass: "pinned"(e.g. sUSDS) is arbitrage-closed to its redemption value, so any measured deviation is feed noise, not exit risk. Its constituent is skipped from thetoken_basisread entirely and the Market Depth modal renders the pinned note ("its price is pinned by instant redemption arbitrage …") in the chart's slot, the same pattern as the never-charted numeraire side. Residual risk lives on the underlying's peg, never the wrapper (the documented blindness trade).
On the debt side the distinction that decides DEBT_LEG is whether the token is the unit of account or merely claims to track it:
- Native dollars and the numeraire (
USDC,USDT,ETH,WETH) are the unit of account, fair by construction on the debt side ->null. - Synthetic / tokenised dollars (
GHO,USDe,USDtb) carry a peg of their own against that unit, and each has traded materially below $1 inside the past year. Owing one is a real (favorable) exit tail, so they are charted.
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 coverage gap is expected today and renders the panel's empty state rather than a wrong chart:
- Pendle PT collateral has no basis series (
pendle-pthas noCOLLATERAL_LEGentry), so a PT carry charts only its debt leg, if any. 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.
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.
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.
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 the BTC wrappers), 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/pnl.ts from the venue readers' (qty, index) snapshots and the flow ledger. The engine has no DB or RPC imports (the tests in pnl.test.ts 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 three books by accounting asset, each denominated in its own unit: USD, ETH, BTC. Price moves of ETH/BTC against USD never appear inside a book (that is the entire point of the partition). A book is a denomination, which is not the same question as which view a position is shown under: a financed position spans denominations and is presented in the Cross-asset view, one curve per unit, still with nothing blended 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), or a dollar-denominated token with no par claim — apxUSD redeems only for whitelisted entities 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. The hard-coded ACCOUNTING_ASSET_BOOKS map is retained as the registry seed and as the pure-context fallback for any token the registry does not carry (a carry-leg exotic, a PT's underlying, a read before the registry loads). Pendle PT addresses are ephemeral (a fresh token per maturity) so they are never in the static map; 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);
- (b) an Aave/Spark account is partitioned by book (the per-book carve-out, coverage-expansion plan rule 6), and each real-book sub-group is gated on its own: a sub-group with debt and same-book supply is included as 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); a supply-only sub-group charts as debt-free; a bare-debt sub-group (debt with no same-book supply — its collateral lives in another or an unmapped book) means the account is cross-asset, and the whole account moves to the Cross-asset view for as long as that holds (M22); the partition below applies only while it does not. 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. Unknown-asset legs go Outside individually.
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 with debt keeps the all-or-nothing same-book rule for its collateral + debt legs, but the pure-lend supply leg (
morpho:market:<id>:supply) is carved out and charts per 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); - (d) a Fluid NFT group is all-or-nothing in one real book even when debt-free (the new M14 cross-book rule below), with no e-mode gate (Fluid vaults are isolated pairs by construction). A vault side holding two different base assets, or holding an asset the registry does not book, is settled once for that side's whole history rather than per snapshot — the first is outside coverage and charts in no view, the second keeps the unknown-asset path (M14, M22). FWS3 landed the flow scanner and flipped the D8 gate on, so Fluid legs now chart by these rules; the
coverage-pendingreason is retained only for older history (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 already strands 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. On the isolated venues 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 — and both belong Outside.
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 M1-excluded ("outside the yield book"): valued, returned by the positions API, never charted or blended — and, since 2026-07-13, not rendered in the portfolio view (the UI shows only charted positions; the WS8 unknown-asset alert remains the surfacing path). The machine reason (InclusionReason) is one of debt-free / same-book-carry / same-book-debt / cross-book / cross-asset / directional-pair / unknown-asset / coverage-pending, which WS6 maps to user copy (one-line rule for users: supply positions always count; a same-book supply+debt loop counts as a carry, e-mode or not; collateral in one denomination funding debt in another is a financed position and charts in its own view; a position whose return is the price of one asset against another is directional and outside coverage). cross-book now names exactly one economic case — a bare debt, borrowing with no collateral standing behind it in the book, labelled "Debt without matched collateral" — reached two ways: an Aave/Spark bare-debt sub-group whose account holds no covered collateral at all, and any debt-bearing group holding no collateral observed or carried (rule e). The covered Aave/Spark bare-debt case it used to cover is cross-asset, the Fluid two-base vault it used to cover is directional-pair (M22), and the "legs span more than one book" cases it was named for are no longer reachable on the read path at all: a mapped Morpho carry spanning books is always cross-asset, and a Fluid group spanning books is either cross-asset (one base per side) or directional (two bases on a side). Those arms remain in classifyLegsAtTs only so the classifier stays total.
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 seven categories, in display order, are repo_lending (a based lend with no same-book borrow: Aave/Spark supply-only sub-group, Morpho supply carve-out, a Fluid Lending fToken, a debt-free Fluid vault NFT), carry_trade (any same-book collateral+debt loop, e-mode or not), 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 lending-venue category follows the inclusion reason (debt-free → repo_lending, same-book-* → 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 balance-diff anchors), 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 balance diffs still book as flows there, to keep the invariant clean and surface the capital move, even though they never touch the zero-yield curve). A non-based fiat idle token (EURC, registry book NULL) is the one carve-out: it is valued in USD (PositionRow.charted = false) and surfaced in the Idle section by a dedicated getPositions path, 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 a wallet leg always carries a (non-null) category — so it is covered, not "outside the yield book". An excluded leg (categoryless: a Morpho cross-book group, a Fluid vault of two base assets, an unknown non-wallet asset) has no category and stays hidden. 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) from token_yield_apy.share_rate (block-anchored 6h series) for history and the wrapper's own on-chain getter (wstETH getStETHByWstETH, ERC-4626 convertToAssets, an LST getRate/getExchangeRate) for the JIT "now" tick. The classification is pure (redemptionRateKind in valuation-math.ts): a par accounting asset (the base stables + WETH/ETH + WBTC/cbBTC/LBTC + the par-rebasing bases stETH/eETH) resolves to IDENTITY_RATE; a rate-source wrapper reads share_rate; a genuinely yield-bearing wrapper with no honest rate source (eBTC, the ether.fi BTC LRT) throws and the leg is skipped (M9) — never defaulted to 1, which would report its appreciation as $0. The weekly sync-portfolio-tokens.ts (T6) ASSERTS this stays true: every WALLET-TRACKED variable_rate row must resolve a rate source (a JIT_RATE_GETTERS entry 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; eBTC is the one acknowledged, quiet exception (a decided skip, not a new gap). 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 (DeFiLlama, batched once per snapshot via loadMarketContext following the token-basis batchHistorical pattern; 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 flow leg gets a signed contribution to the book's net value (signedFlowValue): deposit / repay / transfer_in are +value; withdraw / borrow / transfer_out are −value; liquidation is 0 (M4). Summing the signed legs of a tx (netFlowsByTx, per (tx, book)) 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.
The cumulative-yield chart is the running sum of segmentYield across all legs (buildBookCurve → points[].cumulativeYield), net of flows by construction (a flow never enters segmentYield, 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.
M4 — liquidations are realized losses, never withdrawals
LiquidationCall (Aave/Spark) and Liquidate (Morpho) are marked kind='liquidation' and are excluded from flow-netting entirely (signedFlowValue → 0): a collateral seizure must never be read as a user withdrawal (which would silently remove the loss from performance). Instead the equity destroyed is booked as a realized loss (buildBookCurve subtracts it from the interval's yield and tracks realizedLoss), so it steps the cumulative curve down and drags the TWR.
The liquidation row's value column is the equity destroyed, computed in flows.ts as the penalty = value(seized collateral) − value(debt covered), per mark (Aave liquidatedCollateralAmount − debtToCover; Morpho seizedAssets − repaidAssets), NOT the full seizure. Booking the full seizure would overstate the loss by the repaid-debt value and drive the TWR to a false total wipeout. The seized collateral still populates amount_raw / amount_underlying (the transferred amount); the value columns carry the loss the PnL engine subtracts. If a mark's price/rate is unavailable, that mark's penalty is null (M9), never a full-seizure value.
An Aave/Spark liquidation additionally burns the borrower's collateral aToken and debt vToken and moves the liquidation fee, which the position-token Transfer scan records as withdraw / repay / transfer_out rows on the SAME tx. Those rows are kept in the ledger (they are real on-chain transfers) but are seizure mechanics, not external user flows: netFlowsByTx and buildBookCurve exclude any withdraw/repay/transfer_out on an aave/sparklend leg that shares a tx with a kind='liquidation' row, so a seizure never nets as a phantom external withdrawal/repayment. (A genuine deposit/borrow in some other tx still nets normally; Morpho emits no position-token Transfer, so nothing there is affected.) Fluid has no position-level liquidation event; FWS3 classifies Fluid liquidations by state diff (M15 below: a vault LogLiquidate triggers a per-NFT diff; the FWS2 read core is liquidation-safe on its own: isLiquidated=1 means a position sits on a liquidated branch, NOT that it is closed, and the resolver reads absorb the seizure).
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; BTC/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). Each curve is computed in one mark end-to-end, and a flow is valued in the same mark as the curve it adjusts (buildBookCurve(legs, flows, mark) picks the matching value column throughout). The two marks are fully independent: the yield in each scales with that mark's own base value.
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/BTC-book consumer reads priceInBook, never usd divided ad hoc.
- Newest-common-bar rule. For an ETH/BTC-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. - The
≈ $Xdisplay annotation is explicitly OUTSIDE this rule. In the ETH/BTC books the signed-in Portfolio prints a muted USD equivalent under its headline figures (usdEquivalent, priced byGET /api/portfolio/prices). That isbookAmount x latest numeraire bar— a cross-bar 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 thebar_tsit came from, and an unavailable bar renders NO line rather than a fabricated one (M9). Applied to a cumulative yield it answers what the earned ETH/BTC is worth today, not what it was worth as it accrued. Nothing in the valuation path may take a price from this route. - Derived-base rate alignment. stETH and eETH have no raw bar slot. Their 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. - 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/BTC-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. - 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. The ephemeral JIT "now" read (live.ts,mode: "now", never persisted) additionally prices registered wrapper carry legs from a KyberSwap aggregator round-trip mid (aggregator-mid.ts): quote 1 unit token→pairToken (a= pairToken per token) and 1 unit pairToken→token (b= token per pairToken); the mid is the geometric mean of the two straddling price estimatesaand1/b, i.e.mid = sqrt(a / b), so route fees and first-order impact cancel; the round-trip half-spread1 − sqrt(a·b)is the market-health guard (30bps majors / 100bps thin). One aggregator mechanism covers every leg (the aggregate route IS the honest market for a fragmented LST), reusing the sharedkyberQuoteclient. The book-unit price isbookPrice = mid × pairToken's own market unit price(1 for WETH; the stable's latest mirror bar ~$1 for a USDC-paired wrapper). The fallback chain per token at JIT is aggregator mid → latest mirror bar (≤48h) → null; a quote failure, a too-thin half-spread, or a breach of the redemption sanity band (|bookPrice/redemptionRate − 1| < 5%) logs and falls through, never throwing the valuation. The mid's context quotes (tokenUsd/pairUsd) and the non-registered-asset fallback also come from the latest mirror bars (stale/missing → null exactly as a missing quote did). The redemption rate for the band is the wrapper's on-chain getter, or the freshesttoken_yield_apy.share_ratefor a wrapper without one (reUSD). Only a successful mid is cached (for the JIT result's 60s window; a transient quote failure is not negatively cached), and concurrent same-token quotes share one in-flight request. The JIT mid is current to within seconds (not block-anchored) — accepted for an ephemeral, never-persisted display tier.mode: "history"NEVER consults the aggregator, so the two methods never mix in stored data. Every wrapper that can be a listed-carry collateral or debt leg must be aggregator-registered or a par unit / Pendle PT; a build-breaking test (aggregator-mid.test.ts) fails CI if a new carry wrapper is listed without a registry entry.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 reads the latest available bar (a lagged-but-coherent basis, ~1–3bps per 6h in calm markets, up to the 48h ceiling) and the next cron sync + cheap re-mark trues it to the exact minute once the bar exists (see M18). This is a design decision, not a bug.
- No DeFiLlama safety net (deliberate, honest per M9). Removing DeFiLlama from the mark pipeline trades a fresh-but-incoherent fallback number for an honest dash. Two consequences, both intended:
- Live-tick double outage. On the JIT "now" tier a registered wrapper whose Kyber quote fails AND whose latest mirror bar is >48h stale / absent now shows a dash (null
value_market), where the old pipeline would have printed a DeFiLlama fallback price. It takes BOTH outages at once, and the mirror one must be 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. - Thin wrappers Dune does not price. The five known non-traded vault shares iETHv2, yoETH, yoUSD, fLiteUSD and yvUSD compose from their underlyings under M5.2, so they no longer degrade to a null market mark. Any other rate-source wrapper with no Dune rows still values with a null MARKET mark (a dash) where DeFiLlama occasionally had a price. This remains the honest-null degradation until that wrapper is explicitly classified for composition; REDEMPTION is unaffected. For a Dune-thin EXCLUDED asset the "Outside the yield book" USD total shrinks by that position's value (the position is still stored with a real
qty_raw; only its USD value dashes).
- Live-tick double outage. On the JIT "now" tier a registered wrapper whose Kyber quote fails AND whose latest mirror bar is >48h stale / absent now 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 mirror-marked stored snapshot and an aggregator-marked "now" — a small cross-source residual (bps; both coherent and near-current) that disappears at the next 6h snapshot. REDEMPTION yield is unaffected. Existing stored history written before this shipped is accepted as-is (repaired separately by Milestone D).
M5.2 — derived par bases and non-traded composition (Phase 1)
Two enrichments sit on top of the raw same-bar mirror read (src/lib/portfolio/mark-enrichment.ts), applied by loadMarketContext, valueFlows, and the standing-mirror remark path:
- Derived par bases (stETH / eETH). Par-rebasing ETH bases whose REDEMPTION is
IDENTITYbut whose MARKET mark must float (USDC's treatment in the ETH book). No new external price source:stETH_market_in_ETH = wstETH_market_in_ETH ÷ wstETH_share_rate(and the eETH ← weETH analogue). Identical ETH-basis with the already-mirrored wrapper. - Non-traded vault composition. For iETHv2 / yoETH / yoUSD / fLiteUSD / yvUSD (no Dune bar of their own):
market = shareRate(wrapper→underlying) × underlying_market_in_bookandredemption = shareRate × underlying_redemption_in_book. Underlying from on-chainasset()(cached). A wrapper that has its own Dune bar (tETH, liquidETH) keeps that bar — never compose.
The mirror-coverage invariant is updated accordingly: every market-class BASE asset is mirror-tracked; non-traded compose wrappers are exempt. See market-redemption-basis-consistency-plan.md.
M6 — Pendle PT: honest MARKET and pull-to-par REDEMPTION (both wired)
A PT carries no share index (index_raw null), so it is valued from a mark's own value series (never a composed index), and the two marks are never blended (the detail is M11/M12 below):
- MARKET marks at the
pt_to_asset_rateTWAP (accounting-asset per PT, read at the anchor block) times the underlying's market price. Fully wired. - REDEMPTION accretes pull-to-par at the position's entry implied yield (M12), derived at read time from the flow ledger (M11), rolling to exactly par (1.0) at or past maturity. Fully wired:
ptImpliedApyFromFill/ptRedemptionAssetPerPt/yearsBetweenare called bypt-basis.ts+assemble.ts(applyPtRedemptionMarks), applied inapi-data.tsloadContextafter the flow ledger loads.
For a PT leg, buildBookCurve takes the interval yield as the change in the chosen mark's value series net of PT flows (v_end − v_start − netPtFlow), so an acquisition is not read as yield while the accretion is. A PT flow is valued at the PT's own rate at the flow block (not at par; see M11), so a 100-PT buy at rate 0.95 books a +95 flow, and a newborn PT nets to ~0 first-window yield rather than booking −5 of phantom negative yield that then accretes back.
A PT held on a lending venue as collateral (Aave/SparkLend e-mode; its reserve underlying IS the PT address) is now valued and charted the same way: the known PT is promoted into its underlying's book (
buckets.ts), MARKET = rayMul-descaled aToken qty ×pt_to_asset_rateat the block × the underlying's book price, REDEMPTION = the same M12 curve. 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.
M7 — the wedge badge
When the two marks diverge on any held asset, a divergence badge shows in both modes. The trigger (assetWedge, bookWedgeDiverged):
|MARKET − REDEMPTION| / REDEMPTION > 0.5% (stables, USD book)
> 1.0% (ETH/BTC wrappers)Thresholds live in one place, exported and tunable (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. The per-book headline tiles do NOT annualize — they show the non-annualized realized return (a simple holding-period return) and TWR, which are honest at any length, so no gate applies there (a book with only a few days of history still shows an honest return, not a dash). Also excluded in v1: MWR/IRR (realized return is a SIMPLE holding-period return, never money-weighted), 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.
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 render as gaps, never interpolated, and history never extends beyond the coverage floor (2025-05-21; PT realized yield only from rpc-basis rows).
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 six authenticated routes under src/app/api/portfolio/* (all force-dynamic, all gated by verifySessionCookie), fed by one server module (src/lib/portfolio/api-data.ts) that turns the stored rows into the pure engine's inputs through a pure adapter (src/lib/portfolio/assemble.ts, unit-tested in assemble.test.ts). 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 history plus the cached JIT result only (first paint never waits on RPC); POST /api/portfolio/refresh triggers the live read after paint and the client re-fetches when it lands (see Portfolio). None of the numbers change with the split — a jit: false response is simply the same methodology evaluated without the live "now" point.
- Re-composing the index. The snapshot row stores the bare venue index (
index_raw) plus the two already-book-valued marks.assemble.tsrecovers the accounting-asset→book redemption rate exactly from the persisted values (rate = value_redemption / qty_underlying) and re-fuses it onto the bare index through the sanctionedcomposeIndex(buildIndexLeg), so a leg's book value stays proportional to its index and the M2 invariant holds through the API. A base-unit asset recoversrate ≈ 1(degenerate); a wrapper recovers its real appreciation. A missing redemption value or a 0-qty leg falls back to identity (that leg has no redemption value to chart anyway). - 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 is open-only and daily before signup. The registration backfill replays ONLY the position groups open when it runs (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 position closed before signup has NO history here by design; a position closed AFTER signup keeps its tracked history (closing an open group prunes nothing — only a manual repair re-run re-derives the open 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, realized return and TWR — both non-annualized — and current book value), backfill status (syncingwhilequeued/running), and the wedge (M7). Realized return is a simple holding-period return (realizedReturninpnl.ts=totalYield / baseCapital,baseCapital= the book value at the first curve point with positive equity;null⇒ a dash, never 0, M9). TWR is the flow-neutralcurve.twr. The two match with no flows and diverge once capital moves. The tiles carry no 30-day gate (both returns are honest at any length); the annualized realized APY was removed FROM THE TILES but survives in the positions table's earned-vs-advertised column (next bullet). A best-effort JIT "now" read (live.ts, 8s budget) is merged as an extra snapshot ts so a five-minute-old position shows; a stalled RPC falls back to the stored history and reportsjit:false.positions— per-book rows (value, native-unit yield since tracking, realized APY vs the current quoted rate) plus the "Outside the yield book" groups with their reason. 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&mark[&bucket]— the chart series, bucket-reduced onto a uniform grid (bucketReduceCurve); a bucket with no snapshot emitsnullso a gap renders as a clean line break (never interpolated, M9). Flow markers are the per-tx net external flow (a leverage loop collapses to ~0); liquidation markers are the M4 realized-loss events.book/mark/bucketare validated strictly (an unknown value is a 400).- The series starts on THIS book's own anchor, not the account's replay start: the last idle-only point before the book's first curve-starting observation, so a book that opened later than the account is not charted from a flat-zero lead-in (
trimIdleLeadIn; the full rule and its guards are in Portfolio).trackedSinceon this response is therefore per book and can be later thansummary.trackedSince. It is presentation only: the curve is built over the whole series, so no headline figure moves. When the series IS trimmed, flow and liquidation markers are clipped to the drawn window so none is rendered clamped to the wrong edge; an untrimmed book keeps every marker it served before. 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. The 1W timeframe requests6h, the raw cron cadence, where each point is its own aligned window.- The merged JIT live read is appended as a
livetip at its true timestamp on the 6h grid (stampedLiveResult.computedAtMs, so the tooltip's clock time is when the chain was read, not when the GET arrived). Flooring it into its window would overwrite the cron snapshot that owns that window. On the 1d grid, and on the?wallet=allaggregate (whose merge steps a uniform grid), it stays folded into its bucket.
- The series starts on THIS book's own anchor, not the account's replay start: the last idle-only point before the book's first curve-starting observation, so a book that opened later than the account is not charted from a flat-zero lead-in (
events— the paged raw flow ledger, most recent first.
The UI (src/components/portfolio/*) is book tabs + a MARKET/REDEMPTION mark switch (mono pill groups, the CarryChart pattern), headline tiles, the PortfolioChart (Recharts 3), the positions table, the "Outside the yield book" section, the wedge badge (shown in both marks when triggered), and the permanent "Base yield only … Tracked since <floor_ts>" footer. The /portfolio page shell stays prerendered/static; all per-user data flows through the four APIs.
The mark switch carries a single InfoTooltip defining BOTH marks against each other (they are only meaningful as a contrast), and it states the M5 rule that protocol oracles are never a PnL mark. Cumulative yield, realized return and TWR each carry theirs on the tiles; the cumulative-yield one 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(signup − 90d, coverage floor, first open-group activity)), and the tempting one-liner ("your last 90 days") is FALSE whenever the first-activity clamp binds, which is the common case. Naming the date answers the reader's actual question, which is what the number is measured from.
M11 — PT entry basis (FWS1)
A PT acquisition flow is valued at the PT's own price at the flow block (the getPtToAssetRate(market, 900) TWAP read at that block × the underlying's book-unit price), never at par of the underlying. A flow whose TWAP reverts (a young market) is left unvalued (M9), never booked at par. Booking par is the phantom-yield bug FWS1 removes: a 100-PT buy at rate 0.95 would otherwise book +100 of underlying (−5 of instant phantom negative yield that then accretes back) instead of +95.
The position's entry implied APY is derived at read time from its acquisition fills (src/lib/portfolio/pt-basis.ts): each buy locks in a yield-to-maturity y = p^(−1/τ) − 1 at its own fill price p (= amount_underlying / ptAmount) and its own tenor τ, and the position's entry yield is the quantity-weighted average of those per-tranche YTMs. Partial sells do not move it. A position that predates the observed flow window uses its opening snapshot's implied rate (qty_underlying / qty_PT, recovered from the row) as one synthetic fill, flagged basisSynthetic. A fill left unvalued (null valuation) is skipped, never assumed par.
M12 — PT pull-to-par REDEMPTION mark (FWS1)
At each snapshot t, the REDEMPTION value of a PT leg is the pull-to-par claim at the entry implied yield:
value_redemption(t) = qtyPT(t) × ptRedemptionAssetPerPt(entryApy, τ(t)) × u(t)where τ(t) = yearsBetween(t, maturity), ptRedemptionAssetPerPt(entryApy, τ) = (1 + entryApy)^(−τ) (exactly 1 at/after maturity), and u(t) = value_market(t) / qty_underlying(t) recovers the underlying's per-token book value at t from the stored MARKET columns. qtyPT comes from qty_raw (÷10^ptDecimals for a directly held PT; rayMul-descaled for an aave/spark PT-collateral leg). It is computed at read time (applyPtRedemptionMarks, assemble.ts); nothing new is persisted (the stored value_redemption stays NULL). The M7 wedge for a PT is |market − redemption| and is expected to be nonzero pre-maturity: it measures the rate-move exposure since entry (market pt_to_asset_rate vs the entry-locked pull-to-par), and both marks converge at maturity (τ→0, rate→1).
M13 — PT maturity handling (FWS1)
A PT never silently leaves the universe while any tracked wallet still holds it. The market universe (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 underlying, so the PT is only in the key), (2) a PT as ANY venue's accounting_asset (the flagship aave/spark PT-collateral loop, where the reserve underlying IS the PT, 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 (a join on pendle position keys alone would lose the aave PT-loop this workstream fixes). This is the EXISTS form of the prior pt_address IN (four UNIONed subqueries) and returns the IDENTICAL set; the change is the ACCESS PATH: arms 2/3 used to sequential-scan ALL rows of the two biggest user tables (portfolio_position_snapshots, portfolio_flow_events) on every cold refresh + 6h tick + backfill, cost O(all-users history). 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, both columns CHECK-lower per 043 so a plain-column index serves the probe), and WITH those indexes the cost is O(#pendle_markets). The perf win is index-DEPENDENT (correctness is not): in the pre-migrate window the OR-of-correlated-EXISTS cannot hoist into the single materialised semi-join the old IN (UNION …) got, so each matured-beyond-grace-and-unheld market can trigger fresh sequential scans of BOTH big tables, which is worse than the old single UNION scan for that window (negligible pre-launch, self-healing the moment 059 lands). Apply 059 promptly, in the same deploy window; the result set is identical either way. Post-maturity the TWAP oracle may revert, so the leg is valued at par (rate exactly 1) instead of dropping; the redemption burn (Transfer to 0x0) is a par-valued withdraw flow. The curve is flat at par between maturity and redemption, with no phantom yield at the universe boundary.
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 includesdustBorrow(same token unit asborrowon a normal leg). 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 also includedustBorrow(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).
Cross-book rule (the new M14 classifier rule). A Fluid NFT group whose legs span more than one real book is EXCLUDED even when debt-free. Since M22 the interesting case is what the position IS. A Fluid side has ONE base asset 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); such a side earns a fixed-income return and is fully covered. A side holding two different base assets (USDC-ETH, WBTC-ETH — Fluid ships these) is a directional position: its return is driven by the price of one asset against the other, which is not fixed income and is not what this product measures. It is therefore outside coverage rather than merely unclassifiable, is charted by no view whether or not it is financed, and carries its own reason (directional-pair, "Directional position outside coverage") instead of borrowing the cross-book one. It stays valued in "Outside the yield book": it is real money.
Coverage 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 verdict is taken once per (vault, side) over all the snapshots in scope (fluidVaultCoverage, 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 being directional says nothing about an NFT of that vault that never borrowed, whose exposure is a plain covered holding and ordinary fixed income. (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 real books at any point holds two base assets, 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 let a directional position into a view it must never appear in. A same-base NFT is unaffected and included: 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 an UNMAPPED pool token, and for the same reason. M14 is all-or-nothing about an unmapped asset, so an NFT holding a leg on a side whose pair includes a token the registry does not book stays on the unknown-asset path at every tick — including a tick where only the mapped token's row happened to appear. Judged per row that tick reads as an ordinary covered position, and the mapped token's value growth over it is the pool's composition drift against an asset the product cannot even price. The verdict 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 stay Outside), and one dropped from the registry does not. The M9 evidence rule extends here too — an unmapped leg observed on both sides of a snapshot counts as held at it, so a dropped unmapped row does not make a position look coverable 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.
D8 gate (flipped in FWS3). FWS2 shipped FLUID_FLOW_COVERAGE = false: every Fluid leg was held "Outside the yield book" with the distinct reason coverage-pending, 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) and flipped this ONE const to true, so the M1/M14 rules above now decide inclusion and Fluid legs chart. The coverage-pending reason is retained in the type/labels for any older history that carries it.
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 (fluid-flows.ts): for a vault with a LogLiquidate in the scan window, every tracked NFT on that vault is diffed across the window anchors, and an NFT whose collateral OR debt DECREASED with no matching LogOperate for that nftId (a real withdraw/repay would emit one) is a liquidation. It is booked ONCE as a realized-loss flow row (kind = 'liquidation', M4 — never a withdraw/repay), valued as the equity destroyed = (Δ collateral value − Δ debt value) at the window anchors in each mark, clamped ≥ 0. Deterministic provenance for idempotency: the row carries the tx_hash + log_index of the LAST LogLiquidate on that vault in the window; multiple partial liquidations in one window legitimately collapse into one row. The row's position_key is the NFT group prefix (fluid:vault:<addr>:nft:<id>, 5 parts) and its asset is the vault supplyToken.token0 (the group's book).
Two engine fixes make the loss land exactly once (both required; a T1-only test would pass while both defects ship because a T1 index leg is qty-agnostic and immune):
seizedKeysByIntervalPREFIX-matches fluid keys. The seizure-skip inbuildBookCurve(which zeroes a seizedvalue/ptleg's value-series contribution so its drop is 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.
api-data.tsselects 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 (viewInputs, api-data.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. The equity destroyed above is (Δ collateral value − Δ debt value) with each side summed in its own leg's denomination, which on a same-book NFT is one number in one unit and on a cross-asset NFT nets an ETH amount against a USD one — a figure in no unit at all, and exactly the FX blending M22 forbids. So a cross-asset NFT's seizure is admitted with both marks withheld (M9: unavailable, never zero). The row still records the seizure, which is what keeps every smart value leg's seizure-driven value drop out of yield, and the chart still marks the liquidation in each denomination the position ran in; only the loss magnitude is unavailable. A same-book NFT is unchanged: one denomination, one curve, one realized loss.
Reporting that magnitude needs the write path to value the seizure per book (two rows, or a per-leg split), which this read-time work does not touch. Until it does, a liquidated cross-denomination Fluid NFT reports its realized loss as zero on the summary while the event itself is visible on both of the position's curves.
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 by signedFlowValue with no side awareness, 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 (bookableNftTransfers, the single gate every transfer-derived row passes through, on both scan paths). 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: flowMarkers (api-data.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 api-data.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 in "Outside the yield book" for an uncovered NFT), 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.
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 (src/lib/portfolio/entry-basis.ts, deriveLegEnteredBasis, called in getPositions). 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 are minute-precise when the go-forward exact-minute true-up succeeded (syncExactMinutes clustered the distinct flow minutes into ≤6h executions and the Dune keys were present) and the standing hourly-common-bar mirror otherwise — both coherent by construction; a JIT flow detected before its bar exists settles to its exact minute on the next cron re-mark (the settle-on-next-cron semantic, M5.1).
Honesty rules mirror M9/M11:
- 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 M12 pull-to-par REDEMPTION mark is already anchored at the PT's entry implied yield, 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). Reading an Aave/Spark PT-collateral transfer flow's basis instead would be wrong (its redemption side is booked at par), so PT legs short-circuit to entered 0. - Pinned-basis-class legs enter at 0 by construction (M19). An arb-pinned wrapper (
basisClass: "pinned", e.g. sUSDS) has its MARKET mark set equal to its REDEMPTION mark at every flow and snapshot, so both marks are identical and its entered/current basis is 0 with no special-casing here. This is the same doctrinal slot as the PT rule above: a measured DEX deviation on such a wrapper is arbitrage-closed feed noise, not information, so it must not enter the dislocation P&L. In the defisaver Morpho sUSDS/USDT case this removes the ~±15bp sUSDS feed noise from entered basis and leaves the genuine USDT discount on the debt leg.
Caveat (Aave/Spark accrued-interest bundling). As in flow netting, an aToken/vToken Transfer value bundles interest accrued since the user's last touch (see M3 / flows.ts NB), 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.
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.
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 AND dustBorrow. For NFT 18524 Dune's
nft_cum_debt_shares792,704,210,084,493,700,000 is the rawLogOperate.debtAmt, missing both the interest accrued since the operate (ourborrow792,704,210,877,197,908,447) and thedustBorrow765,841,398,163,786,432 that Dune never sees. Our reader sumsborrow + dustBorrowand reads the accrued amount, so it is strictly more correct. - 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 (arb-pinned wrappers: basis ≡ 0 by construction)
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: instant ERC-4626 redeem() into USDS, PSM-pinned 1:1 to USDC). Any DEX premium / discount is arbitrage-closed within blocks, so a measured deviation is feed noise, not information. Such a token is tagged basisClass: "pinned" in the token registry (src/lib/data/basis.ts for carry legs, MIRROR_EXTRA in src/lib/data/dune.ts for the mirror-only wrappers); the class is required on every tracked wrapper (there is no implicit default, and the coverage test fails the build on a missing class). The address-keyed isPinnedAsset in dune.ts is the single source of truth every valuation surface reads. For this class MARKET mark ≡ REDEMPTION mark (basis ≡ 0) by construction — the same doctrinal slot as PT legs' entered-basis-zero (M12) — enforced identically across all seven surfaces:
- Flow valuation (
valueFlows): a pinned flow's MARKET unit price := its redemption rate at the flow block (the same rate object the redemption mark uses), sovalue_market = value_redemptionexactly; no mirror bar is read. - Snapshot valuation (
buildSnapshotRow): same rule, history and now; a Dune-unpriced pinned vault therefore gets a redemption-anchored value instead of a null mark (a coverage bonus). loadMarketContext: for a pinned asset the book-unitpriceInBookand theusdmap entry :=redemption_rate × numeraire USD, more accurate than the noisy quote, in both history and now modes.- Live tier: pinned assets skip the Kyber aggregator mid entirely; live market := current redemption, so entered/current/dislocation are 0.
- token_basis refresher: a pinned token writes
market_price_usd := redemption_value_usd,basis = 0, go-forward only (no destructive rewrite of historical rows, which the modal simply stops reading). - Market Depth modal: a pinned leg is not charted; it renders the pinned note ("its price is pinned by instant redemption arbitrage …") in the chart's slot, following the never-charted-numeraire pattern.
- Repair (
remark-*scripts): a pinned row re-marksvalue_market := value_redemption(a row with a null redemption is left untouched and logged), preserving dry-run / execute / idempotency discipline.
Blindness trade (documented wherever the class is applied). Pinning extends the USD-book par assumption THROUGH the wrapper: a depeg of the UNDERLYING (USDS losing its PSM peg) would NOT appear in the wrapper's basis. This mirrors the known Fluid sUSDe-oracle blindness note; the residual risk is charted, if at all, on the underlying, never the pinned wrapper. An asset qualifies as pinned ONLY when redemption is instant, permissionless, fee-negligible, capacity-unbounded in normal operation, and settles into the book numeraire (or a real on-chain 1:1 PSM); every border case defaults to market-measured (the honest side).
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.
buildBookCurve (src/lib/portfolio/pnl.ts) therefore treats an unexplained appearance or disappearance as a coverage event, not a P&L event. Three transitions, all held to the same standard: book 0 rather than a number the ledger cannot support, and record why.
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 and broader than a single edge case: a genuine move of more than the band INSIDE the birth interval is forgone, which reaches both legs of every Fluid smart pair (they drift against the deposit split), 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.
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 the headline tile. Consequence to know when reading a chart: during a coverage gap the book-value line dips while the cumulative-yield line stays flat. The dip is honest (we could not read the leg); the flat yield is the point.
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 (api-data.ts) 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
An Aave/SparkLend account holding collateral in one denomination against debt in another is a financed position: the collateral is not earning free and clear, it is pledged. 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 / BTC views are honestly unlevered.
Membership (groupIsCrossAsset, pnl.ts). At a snapshot ts, let S be the real books the position holds an asset leg in and D the books it owes a debt leg in. The position is cross-asset iff some b ∈ D is not in S. Presence-based, no magnitude threshold, so a 90%-repaid loan is still a financed position and only full repayment of the bare book reverts it.
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 |
EXCLUDED legs are in neither S nor D. On Aave and SparkLend they keep the unknown-asset path individually while the rest of the account moves; on the isolated venues the group is all-or-nothing, so one unmapped leg keeps the whole position on the unknown-asset path and nothing about it charts.
Fluid resolves each SIDE's base denomination rather than reading the vault's books as a set, because a vault has exactly one collateral side and one debt side and is financed when their bases differ. A normal side is a single leg and its base is that token's book. A smart side is a DEX pair the reader decomposes into one leg per pool token (M14), and its base is the book the two share: wstETH/ETH is ETH, USDC/USDT is USD. Such a side is fully eligible — an ETH-based smart collateral funding a USD-based smart debt is exactly what this view exists for.
The one residual carve-out is a vault holding two different base assets on one side (Fluid ships such pools, e.g. USDC/ETH, WBTC/ETH). Its return is driven by the price of one asset against the other, which is a directional position and not fixed income, so it is outside what this product covers: no view charts it, financed or not, and it is reported in "Outside the yield book" under its own reason (directional-pair, "Directional position outside coverage") rather than under the cross-book one, which describes a symptom instead of the position. That is a coverage verdict and it is taken once per vault SIDE over the whole history in scope (fluidVaultCoverage), 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 covered collateral at all (S empty: everything it holds is an unmapped asset, or its collateral went unread) is not cross-asset, and it is not charted anywhere else either: its bare debt goes Outside as
cross-book(M1 rule e). Charting that debt alone 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.
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. Inside the view each leg keeps its own denomination and charts on its own curve: ETH collateral accrues on the ETH curve, the USD borrow cost shows as negative yield on the USD curve. There is no combined figure, because producing one would require an exchange rate and would put price moves between denominations inside a yield number. The rule survives aggregation: over "all wallets" the view reports one block per denomination, each merging only the wallets holding that denomination, and never one across denominations. It survives presentation too: the view renders one cumulative-yield chart per denomination held and lists the position leg by leg, each leg's quantity, rate, yield and value in its own unit, with no tracked-value rail and no total anywhere on it (see what the user sees).
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 (api-data.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.
Where each metric is read
| Metric | Reader | Page |
|---|---|---|
| Trailing APY (any venue) | getTrailingApy (apy.ts) | all |
| Portfolio book yield / TWR / realized APY, marks, wedge | src/lib/portfolio/pnl.ts (buildBookCurve, linkTwr, annualizeTwr, classifyLegsAtTs, assetWedge) + segments.ts (computeViewSegments) over portfolio_position_snapshots + portfolio_flow_events | Portfolio (/portfolio) |
| Entry basis / dislocation P&L (M18) | deriveLegEnteredBasis (src/lib/portfolio/entry-basis.ts) per leg in getPositions, fused by positionEnteredBasis / positionBasisDelta (signed-in-model.ts) | Portfolio (/portfolio), expanded carry detail |
| 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) | 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. Reads a true 0% when the fund's oracle published no update inside the window (routine on the short windows: earnETH's Mellow oracle can hold for ~27 days), and 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 | Multi-Strategy Funds (/multi-strategy-funds) |
| SOFR series / index | getSofrSeries, getSofrIndexRows (sofr.ts) | Asset profiles, Multi-Strategy Funds, 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 (borrowed / deposited); target read on-chain via getTargetUtilization (target-utilization.ts) | Repo lending (/repo-lending) |
| Repo-market lender rate, where none exists | not quoted: a reserve that routes all borrower interest to the DAO (100% reserve factor) is flagged noLenderYield, confirmed against the data each read, and the APY cell reads "No lender rate" (see below) | 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).