Pendle PT coverage in portfolio accounting (#811 program)
Settled with Fred 2026-09-10. Supersedes the proposal text of #811 where they differ. Base: staging after PR #826 (token + fund registries, composition function, hardened mirror). Integration branch: feat/pt-coverage-811. Prod holds zero users (full wipe 2026-09-10), so nothing here restates anything and no per-wallet data step exists; the only prod data step is market-level (§B3) and is gated.
0. Decisions (recorded on #811)
- D1 (P1 accepted). A PT is a composed asset over its payout asset (
pendle_markets. underlying_address), never a quoted one.ptRate(block)=getPtToAssetRate(market, 900)before maturity (already carries the impairment factor),f(block) = min(1, SY.exchangeRate() / YT.pyIndexStored())at/after maturity. A failed read leaves the mark NULL (M9), never par. No PT ever gets a price bar. - D2 (P2 reduced).
pendle_marketsis NOT merged into the fund registry. The PT rate becomes one method in the composition function (unit-prices.ts/rate-getters.ts, getter kindpendle_pt), and BOTH the level path (snapshot.ts) and the flow path (derive/marks.ts) resolve a PT's rate through it.PT_COLLATERAL_VENUESstays as #754 left it (quantity shape only). - D3 (P3 amended). Coverage is holdings-driven, never listing-driven. Payout assets covered NOW: trUSD, USDat, iUSD (USD book, Dune tape, wallet-tracked). The other 16 untracked payout assets (ENA, USDx, YUSD, STRCx, USN, fxSP, ROY-JT-apyUSD, BOLD, AID, OUSD, ROY-ST-apyUSD, NUSD, avUSD, cUSD, msUSD, wYLDS) stay untracked: a held PT on one of them is shown under Other as "payout asset not tracked", never silently skipped. No auto-tracking of a payout asset when a market is listed. apxUSD/apyUSD stay in Other (2026-09-02 decision); no bar purchase for #753.
- D4 (P4 accepted, amended). The accrual (REDEMPTION) line of every PT leg is computed at READ time in the engine-input assembly (
ledger-v2-engine-input.ts), the same seam the entry basis, the Morpho impairment re-labelling and the #802 bridge use. Nothing new is written to the position spine or the flow ledger. Accretion is per purchase lot at each lot's own locked yield; the yield is derived from the impairment-adjusted fill price (§C1). The impairment factor is a market-level series stored inpendle_market_state(§B1), joined at-or-before with a staleness bound. - D5 (P5 accepted). Flows on a PT leg take the same method at the flow block on both lines, so an internal move nets to zero.
- D6 (#757 folded in). The dated impairment event is derived from the market-level factor series (a step down while a tracked leg holds the market): position-row flag, Activity entry, annualised APY withheld over the impaired interval via the existing
impairedIntervalshook. No separate ingester, no new table, no number added. - D7 (#794 closed by this program). The accrual line gives every PT collateral leg a redemption mark. Its second half (a financed group publishing the borrow alone when a member leg has no mark) is fixed generically in §C5.
- Working rules. Opus agents only. No e2e, no Playwright, no browser passes. Required on every PR: unit tests for the change,
npx tsc --noEmit,npm run typecheck:scripts,npm test(manifest-enforced),cd docs && npm run build. Migrations additive, noBEGIN/COMMITin a migration file, no destructive tag strings in prose, prerendered readers of a new column probeinformation_schemafirst. Every hand-run script callsinitRegistries()at startup. No prod step without Fred's explicit go-ahead. Long commands run in the background. Node 20:export PATH=$HOME/.nvm/versions/node/v20.20.1/bin:$PATH.
1. Phases, ownership, order
A ∥ B → C → D → E. Each of A–D is one PR into feat/pt-coverage-811, written by an implementer and reviewed by an independent reviewer until zero blockers; the orchestrator merges. E is the single PR from the integration branch into staging plus a final review.
Phase A — payout-asset coverage and the coverage rule
A1. Registry rows. Add to src/data/token-registry.ts (the seed GENERATES the migration's VALUES block; the drift test fails the build if either side is hand-edited):
| symbol | address | book | valuation / feed |
|---|---|---|---|
| USDat | 0x23238f20b894f29041f48d88ee91131c395aaa71 | USD | market / dune_tape |
| trUSD | 0xd0580192e98ea6ceb9c7b6191ed2e27560911697 | USD | market / dune_tape |
| iUSD | 0x48f9e38f3070ad8945dfeae3fa70987722e3d89c | USD | market / dune_tape |
idle(...) rows, walletTracked: true, source: "manual", name/issuer filled (Saturn Credit; Tori Finance; InfiniFi). Verify decimals() on chain for each; do not assume 18. Verify Dune prices.hour coverage for each address the way the mirror's coverage preflight does (mirror-coverage.ts) BEFORE choosing dune_tape; if a token has no tape, use the feed the registry already supports for that case and say so in the PR. The migration is the next free number in scripts/sql/; its header states that the history load is a prod runbook step (Dune credits) and that staging never backfills. Register any new icon per the icon-registry rule if a row needs one (check reference_creddit_icon_registries conventions in src/); do not invent assets.
A2. Coverage rule. A PT leg (bare pendle:pt: leg, or a PT-collateral leg on any venue in PT_COLLATERAL_VENUES) whose payout asset has no token row, or a row with book IS NULL, resolves to the Other view with a NEW outside reason payout-asset-not-tracked (label: "Payout asset not tracked"), valued at market where a price exists and otherwise unvalued, and is named by the unknown-asset alert (WS8) with the payout asset's address and symbol. Today an unknown payout asset is an M9 skip with no log line; that path must no longer be reachable for a PT whose market is known.
A3. Census. A read-only census line (in the existing coverage report, v2/coverage.ts, or the ledger reconcile output, whichever already names outside groups) listing every tracked leg whose asset is in no registry, PT or not (#703 item 3). A unit test that every pendle_markets row's payout asset either has a token row or resolves to payout-asset-not-tracked (never to a silent null), driven off the fixture registry.
Acceptance: fixture corpus unchanged for every non-PT leg; a PT on an untracked payout asset appears under Other with the reason; the three new rows pass the registry drift test and the generic coverage test from #826.
Phase B — the impairment factor as market data
B1. Column + writers. Additive migration: pendle_market_state.redemption_index_factor numeric NULL (and py_index_stored numeric NULL next to the existing sy_exchange_rate, so f is reproducible from the row). scripts/refreshers/pendle-markets.ts writes both on every snapshot, pre- AND post-maturity, from the batched read that readRedemptionIndexFactorsAtBlock already performs; scripts/backfill-pendle-history.ts writes them for every daily row it produces (archive read at the row's block). Nothing on a prerendered page reads the new columns (carries-table.ts, apy.ts, pt-depth.ts read this table for prerendered routes — leave their queries untouched).
B2. Read-side series. A loader ptFactorSeries(marketAddress) returning (snapshot_ts, block_number, f) ordered, and factorAt(series, ts) = the latest row at or before ts whose factor is non-null, bounded: if that row is older than 2 × the refresher cadence (6h cadence → 12h; daily backfill rows → 48h), return null.
Amended in build, accepted 2026-09-10. The 6h bound ships at 18h, not 12h. A cron row is STAMPED with its window's floor and READ at the head block minutes later, so its age measured from the label overstates its evidence by the cron's own latency; at a flat 2× the recovery row after a single missed tick lands just AFTER the previous row expires and a live PT figure blinks to a dash for those minutes for no reason a reader could understand. One extra window absorbs the label-to-read offset and nothing more. The daily bound stays at a clean 2×, because an API point's timestamp IS the instant its factor was read at or before. A null factor withholds the accrual value (M9); it is never treated as 1. Document the bound and its reason (a factor only moves on an impairment, and the bound is what stops an impairment from being carried across a refresher outage as par).
B3. Runbook (prod, gated). After deploy: the standing 6h refresher fills the factor going forward. Historical rows stay null until backfill-pendle-history.ts --market <m> is run for a market a tracked wallet holds (the per-market run #720 already sequences on a holding). State the RPC cost per market-year (2 calls per row). No bulk run over the 76 markets.
Acceptance: f on a fresh snapshot equals redemptionIndexFactor(sy, py) to the wei; matured impaired markets in the fixture (0x8cef2919… shape) carry f < 1 and unimpaired ones carry exactly 1; factorAt returns null beyond the bound.
Phase C — the accrual line, per lot, at read time
All of this lives in the engine-input assembly and helpers under src/lib/portfolio/v2/ (new module pt-accrual.ts, pure, with its own spec). Inputs per PT leg: the leg's spine rows (qty_raw, qty_underlying, value_market, block_ts), its flow rows (qty_delta, amount_underlying, value_market, basis, ts), the market (maturity_ts, decimals), the factor series (§B2), and the payout asset's redemption value per unit in the leg's book (u_red(t); 1 for an identity asset of the book, else the existing redemption-rate resolver for that asset at that block).
C1. Lots. Walk the leg's flow rows in ledger order. An acquisition (qty_delta > 0) opens a lot: q_i = qty_delta, fill price in payout units per PT p_i = amount_underlying / q_i (verify that amount_underlying on a PT row IS the payout-asset amount the row was valued at — for a market-buy receipt that is the TRUE FILL from the consideration, for a mint or a venue deposit the oracle mark at the block; if it is not, derive p_i from value_market / (u_mkt(t_i) × q_i) and say which in the module header), f_i = factorAt(t_i), τ_i = (maturity − t_i) in years, p̂_i = p_i / f_i, y_i = p̂_i^(−1/τ_i) − 1. A disposal (qty_delta < 0) reduces every open lot pro-rata; lot yields never change. A row with p_i null, f_i null or τ_i ≤ 0 leaves the lot unvalued, and an unvalued open lot withholds the leg's accrual value on every later interval (never assumed par, never skipped silently: emit the W2 withhold reason pt-lot-unvalued).
Amended in build, accepted 2026-09-10. A lot acquired at or after maturity (
τ_i ≤ 0) is NOT unvalued: it carriesy = 0, so the level formula givesq × f × u_red— the claim the redemption index states exactly. Withholding it would publish a dash for the rest of the leg's life over a number that is known, and nothing is assumed par:fwas read. The unvalued case stays what it was for a null fill and a null factor. A leg whose first evidence is a LEVEL row (predates the flow window) opens one synthetic lot from that row:q = qty,p = qty_underlying / qty,f = factorAt(that ts), flaggedbasisSynthetic.
C2. Level accrual. For a spine row at t: value_redemption(t) = Σ_i q_i(t) × f(t) × (1 + y_i)^(−τ(t)) × u_red(t), with τ(t) = max(0, maturity − t). At/after maturity this is qty × f(t) × u_red(t).
C3. Flow accrual. An acquisition row: value_redemption = q_i × p_i × u_red(t_i) (what was paid; equal to the row's market value by construction when the payout asset is an identity of the book). A disposal row: q × [the leg's accrual value per PT at t], i.e. the same curve the level uses, so a wallet→venue transfer, a partial sell and a burn at maturity all net to zero on the accrual line.
C4. Surfaces. The PT position row shows a FIXED APY = quantity-weighted y_i over open lots (replaces the null); redemptionFacts keeps derivedRedemptionMark: true; the row carries impaired: { factor } when factorAt(now) < 1; the Activity feed gets a dated entry when the factor steps down during an interval the leg is held ("PT-jrUSDat impaired: redeems at 0.46 per unit"); the interval joins impairedIntervals so the annualised figure is withheld over it (existing rule). Nothing else on the surfaces changes.
C5. #794 second half. In a financed group (carry_trade or any supply+debt loop), a member leg with no mark on a line withholds THAT LINE for the whole group with reason member-mark-missing, instead of publishing the borrow alone. Scope it to financed groups only; thin-wrapper exclusions on standalone legs are untouched. Show the fixture diff in the PR: which groups change and why.
C6. Docs and stale comments. docs/metrics.md: rewrite M6, M6a, M11, M12, M13 as one rule under a new M-rule ("Pendle PT: composed over the payout asset, per-lot accrual at read time, factor on both lines"); delete the "known gap" note in M13 (closed). Correct the comments that still claim a read-time curve exists or that nothing writes it (derive/marks.ts header, readers/pendle.ts header, snapshot.ts PT branch, api-data.ts "NULL by design (M12)"). docs/ build must pass (dead-link check).
Acceptance (unit tests on the fixture corpus, every one a real assertion with magnitude-unique fixtures, never vacuous):
- I1 an acquisition's accrual value equals its market value at the fill block (what was paid), so the buy nets to zero on both lines;
- I2 between flows the accrual value moves only by accretion at the lot yields, by a factor step, or by
u_red; - I3 at/after maturity the PT rate on both lines is
f(accrualqty × f × u_red, marketqty × f × u_mkt), so the lines differ only by the payout asset's own market-vs-redemption wedge, which is zero for an identity asset; - I4 a lot bought on an already-impaired market derives its yield from
p / f, and the level after the buy equals what was paid (no double-counted impairment); - I5 a null fill, factor or rate withholds with a named reason, never par;
- I6 a partial sell leaves every remaining lot's yield unchanged;
- I7 wallet → Morpho/Aave collateral transfer nets to zero on both lines;
- I8 a levered PT loop's book value under the accrual toggle is
collateral − debtand positive for the fixture loop (wallet0x5d09098f-shaped fixture); - I9 a write-down appears once on the market line, once on the accrual line, once as a dated event, and the annualised APY is withheld over that interval;
- I10 the market line is byte-identical to before this phase on the whole fixture corpus.
Phase D — one rate method (D2)
Add getter kind pendle_pt to the composition function: given a market (pendle_markets row via the existing loaders), a block and a timestamp, it returns the two-regime rate (ptToAssetRateAtBlock is the implementation; move or wrap, do not duplicate). snapshot.ts (both PT branches) and derive/marks.ts (the (market, block) rate map at ~L775–810, which today re-implements the regime split) call it. A boundary test asserts no other module reads getPtToAssetRate or the redemption-index pair directly. Acceptance: market marks byte-identical on the fixture corpus (existing morpho-pt-collateral, pt-redemption-index, pt-maturity, marks specs plus a corpus-wide snapshot assertion).
Phase E — integration
One PR feat/pt-coverage-811 → staging with: what changed and why (plain language for the top section), the runbook section (§A1 history load for the three tokens; §B3 factor backfill on demand; both gated on Fred), the fixture diff, and the list of issues it closes (#811, #757, #794; #703 and #753 were closed 2026-09-10 as superseded). Final review by a fresh reviewer over the whole diff, converging at zero blockers, before the orchestrator pings Fred.
2. Out of scope
Pendle YT/LP; #764 (carries-page caption); #303 (carry haircut); moving any of the 16 untracked payout assets; a per-PT price feed; merging the PT registry; re-deriving prod.