The PT row a bond trader would want: return breakdown and an exact locked-in rate
Decision record for the change Fred asked for on 2026-09-23: an expandable Pendle PT row on /portfolio that attributes the position's return the way a fixed-income desk does, plus three fixes that make the locked-in rate EXACT rather than approximately right.
1. What prompted it (verified on prod 2026-09-23)
Wallet 0x3bd8690a994fb2dd4c17bcc386da1b3b900ad8b5 (added to Fred's account 07:57Z) bought PT-reUSD-10DEC2026 twice, both paid in USDC, the PT's own payout coin:
| fill | block time (UTC) | PT received | USDC paid | locked yield from the coins |
|---|---|---|---|---|
| 1 | 2026-09-18 06:23:59 | 46,212.844176 | 45,165.587148 | 10.64% |
| 2 | 2026-09-21 17:49:23 | 41,180.816458 | 40,294.822289 | 10.54% |
Served row at 09:55Z: locked-in rate 10.72% (quantity-weighted), yield earned (accrual) +$80.13, total return +$5.02, market value $85,443.96. The exact blend of the two fills is 10.59%. The 0.13-point overstatement is the USDC price: each fill was valued in dollars at USDC's market bar (0.999638 and 0.999873) and then struck against a payout counted at exactly $1, so the lot looks cheaper than it was. On a PT with ~0.22 years to run, 3.6bp of price is ~0.16 points of yield.
The row's total return minus its accrual is -$75. Measured against the market's own mid at each fill (6h market rows: 10.89% at 09-18 06:00, 11.00% at 09-21 18:00), roughly -$63 of that is what the buyer paid ABOVE the market mark (Pendle's swap fee and price impact) and roughly -$15 is the market rate rising to 11.04% since. The row shows none of this today.
The second prompt: purchases of PT-reUSD between 2026-09-01 and 09-11 made after ~18:00 UTC open an UNVALUED lot (blank rate and yield for the life of the position). The market's factor series was backfilled once a day at 00:00 onto rpc-basis rows, and an rpc row answers for 18h only (stalenessBoundSeconds). Wallet 0xea88…7974 shows the pre-window form of the same blank: its opening reading (2026-08-24) predates the series, so its stand-in lot is unvalued for good.
2. Decisions (settled with Fred 2026-09-23)
D1. The expanded PT row states the OPEN POSITION since purchase, at amortized cost (revised 2026-09-23 after review: Fred chose the locked-rate convention; the first version split the window's total return into carry, trading costs and market moves, which counted the trading cost twice, since carry is struck from the price paid, and was misled by gaps in the history). A point-in-time statement of the PT still held, at the leg's latest reading, independent of the engine's window lines, in the payout coin's own units counted at par (u_red); the row's Value column keeps its own market figure and the panel's caption says so:
cost = u_red x SUM q_i x p_i (open lots, the D2b striking rule; a stand-in
lot's cost is its opening value, flagged
"since tracking started")
BV = u_red x f_t x SUM q_i x (1 + y_i)^(-tau_t) (at your locked-in rates)
MV = u_red x qtyPt x P_t, P_t = qty_underlying / qtyPt
carry = BV - cost mark to market = MV - BV total = MV - cost = carry + MTM
- Carry is what the position has earned at the locked-in rate; the mark to market is how far the market value sits from the locked-in value, and fades to zero by maturity.
- The mark to market splits where every open lot has its purchase-time market yield
y_m,i(from the stamps; a stand-in lot hasy_m = y_i, so its entry term is 0), withy_now = (P_t / f_t)^(-1/tau_t) - 1: rate change since you bought= SUM q_i f_t u_red [(1+y_now)^(-tau) - (1+y_m,i)^(-tau)], and your price vs the market then= SUM q_i f_t u_red [(1+y_m,i)^(-tau) - (1+y_i)^(-tau)]. Both fade to zero by maturity. Otherwise the mark to market is shown unsplit. - At or after maturity
tau = 0: the mark to market is 0 and carry isq f u_red - cost. Any missing input (no price, nof_t, an unvalued open lot, lots that do not hold the reading's quantity) nulls the whole statement: dashes, never zeros. - No window-span breakdown, no trading-costs line, no "not broken down" line. The purchases table keeps each lot's market rate then. Realized P&L on sales is out of scope (the section is the open position). Profit if held to maturity uses the same cost basis. A PT held in several wallets sums the per-wallet money figures; rates by quantity.
D2. The locked-in rate is exact. Three fixes, all from on-chain facts:
- D2a factor at the fill block. The flow valuation stamps the redemption-index factor read at the receipt's OWN block onto every PT receipt (
meta.ptFactor, two archive reads, deduped per (market, block), the same batched reader the matured-PT rate already uses). The accrual derivation prefers it over the stored series for that receipt; the series stays the fallback for rows written before this change. Blank rates from the reading cadence stop existing for any new or re-derived purchase. - D2b the fill in payout units, one rule. Every stamped market trade is struck from what was paid converted into the payout coin at the payout coin's own bar at the fill:
value_market / px,px = ptMarkValue / (q x ptRate)(review S3). For a trade paid in the payout coin that is the coin count exactly. A row derived before the stamps keeps the coin count (amount_underlying) where it states one andvalue_market / u_redotherwise. The accrual line still nets the buy to zero on both lines (the flow's accrual value ispayout x u_red, the level right after is the same number by construction of the lot's yield). - D2c purchases before the history window. A bare PT held when tracking starts is struck today at the opening reading's price (the stand-in lot). The Pendle router stream is stored whole from 2025-05-21, so the real purchases are usually on file. At the end of every full wallet build the wallet's router fills for each held PT (receiver = wallet, block before the opening reading) are decoded, valued by the SAME valuation resolver the ledger uses for in-window receipts (so they carry the same stamps and the same conventions), and stored as pre-window fills. They are used only when their net PT quantity equals the opening reading's quantity EXACTLY (raw units) and every acquisition among them valued and read its factor; otherwise nothing is stored and the stand-in stays (flagged). They feed the accrual derivation's lot book only, as receipts that precede the first reading; the engine, the chart and the ledger never see them.
D3. Market rate now is the yield implied by the leg's own latest reading, the same price its market value uses: y_now = (p_now / f_now)^(-1/tau_now) - 1, p_now = qty_underlying / qtyPt. Null at or after maturity (the row says "Matured" instead).
D4. Rate sensitivity is an exact repricing, not a duration approximation: the change in the row's market value if the market rate were 1 point higher, MV x (((1 + y_now + 0.01) / (1 + y_now))^(-tau_now) - 1). Negative. Zero at maturity.
D5. Profit if held to maturity is redeem amount x u_red - remaining cost basis, where the cost basis is each open lot's payout paid (pro rata after sells) x u_red. When any open lot is a stand-in, the figure is flagged "since tracking started" (its cost is the opening value, not what was paid).
D6. Cost to sell now, as ONE live sell quote against the current value (revised by Fred 2026-09-23; the first draft showed market depth only, on the premise that a live quote needed the aggregator the pricing ruling keeps in the calculator, which was wrong: the calculator prices a PT leg through Pendle's hosted SDK). Fetched only when the row is opened, by an authenticated route (/api/portfolio/pt-exit-cost, the session and tracked-wallet check every portfolio route makes, rate-limited per IP, no-store, one quote per market and size per minute per process). The size is the account's own holding at its wallets' newest readings, read server-side. proceeds = the full PT balance quoted PT -> payout coin (pendlePrice, moved from the swap-cost route into src/lib/data/pendle-swap.ts, behaviour unchanged); reference = PT count x the PT rate read NOW by the one PT rate method at the chain's current block; both carried into the book by the row's own converter (value_market / qty_underlying); cost = reference - proceeds, signed and shown signed. If the live rate cannot be read, the row's reading stands in only when under ten minutes old; otherwise, and on any quote failure, "quote unavailable". Pool liquidity and the position's share stay beneath it as context. Omitted in as-of mode and for a matured PT.
D7. Scope. The expanded panel is for bare PT rows (the fixed-rate band). PT collateral legs inside carries get D2a/D2b automatically (same derivation), not the panel.
D8. Purchases table. One line per OPEN lot, sold portions removed pro rata: date, PT amount, price paid per PT in the payout coin, locked-in rate, the market's rate at that block (from ptRate and ptFactor, when stamped), and where it came from: bought, minted, moved from another position (keeps the source lot's facts), bought before tracking started (D2c), held when tracking started (stand-in).
D9. As-of mode serves the panel as of the selected day (it is derived from the rows up to it); market depth is omitted.
3. Build
3.1 Write time (derive)
derive/marks.ts: for every row whose own asset is a PT of a known market, read at the row's block (deduped per market+block): the factor (readPtFactorpair) and the PT rate through the one PT rate method (pendlePtRateAt), and for consideration-valued rows ALSO the row's value at the PT's own mark (the asset-basis valuation a mint gets). Return them onRowValuation(optional fields), andderive/index.tsmerges them into the row'smetaas JSON numbers, camelCase like the keys the Pendle deriver already writes there (route,syInterm,valuationBasis):ptFactor,ptRate,ptMarkValue. A failed read leaves the key absent (never a guess).derive/writer.tsalready persistsmeta; confirm the merge/upsert path rewrites it on a re-derive.
3.2 Pre-window fills (D2c)
- New migration
scripts/sql/113-pt-prewindow-fills.sql(110/111/112 are taken on the open pricing branch): tableonchain_credit.portfolio_pt_prewindow_fills, keyed (chain_id, wallet, position_key, tx_hash, log_index), block-anchored, FK wallet -> accounts(uid) ON DELETE CASCADE (the flow ledger's convention, so every wipe and partial delete takes it), grants as the other per-wallet tables. Columns: block_number, ts, qty_raw (signed PT raw units), value_market, payout_units (coin count where the consideration is the payout coin, else null), pt_factor, pt_rate, pt_mark_value, route, opening_block (the reading they explain), computed_at. - New module (pure core + thin DB/RPC shell): find fills via the existing indexes (topic2 = market for the two swap events, topic3 = YT or receiver for the mint/redeem events), decode with
decodePendleRouterFill, filter receiver = wallet, reconcile against the opening reading, value throughcreateValuationResolver, write all-or-nothing per leg in one transaction (delete the wallet's rows first: idempotent). - Hook: end of a successful full build (
runBackfillWallet), best effort: a failure logs and the stand-in stays. A--freshrebuild recomputes. - Loader: one query per wallet; tolerate the table's absence (42P01 -> none), so the migration may land after the deploy.
3.3 Read time
ledger-v2-loader.ts: projectamount_underlyingand the three meta keys with type-guarded extraction (a projection that can throw takes a wallet's history down; seebirth).v2/pt-accrual.ts: receipts carryfactorAtBlock,payoutUnits,ptRate,ptMarkValue,prewindow; acquisitions and disposals preferfactorAtBlock; acquisitions strike frompayoutUnitswhen present; lots carry their acquisition facts; the result adds per-leg open lots. Pre-window receipts are accepted only when their net quantity equals the first reading'sqtyPt; they never produce a flow value or a withhold the engine can see.ledger-v2-api.ts:PositionRow.ptDetail(additive, bare PT rows only) with D1, D3-D6, D8.api-types.ts: thePtDetailtype, documented field by field.
3.4 UI
- Fixed-rate band: a PT row with
ptDetailbecomes expandable (same disclosure pattern, focus and motion rules as the carry row). Panel sections: Return breakdown, Rates, To maturity, Purchases, Market depth. TradFi wording, economic tooltips only, no em dashes, works at 1360 / 1140 / 900 and narrow widths.
3.5 Docs, tests
- Docs in the same PR:
docs/portfolio.md(the panel),docs/metrics.md(every formula above),docs/database.md(table + meta keys),docs/data-pipeline.md(stamps + pre-window fills),docs/ops/release-steps.md(migration 113, additive, after deploy,executed: pending), this plan's header, and the regenerated plans index. - Unit: stamp fan (dedupe, failure leaves key absent), loader projection (bad meta never throws), exact-factor preference, coin-count strike and I1 netting, pre-window reconcile (match, mismatch, pro-rata sells, unvalued acquisition refuses all), lot metadata through a paired move, the D1 identity to the cent, D3/D4/D5 arithmetic including maturity and impairment.
- e2e: fixture PT row expands; panel structure, the identity, lot count; fails on revert.
4. Acceptance on real data (staging DB, never prod)
Build 0x3bd8…b5, 0xea88…74 and 0xf629…c4 with the branch's code against creddit_staging (accounts rows by hand, as the #931 staging run did), then read positions:
0x3bd8: two lots at ~10.64% / ~10.54%, blend ~10.59% (exact to the formula over the coin counts), market rate ~11.0%; open position (D1 as revised) about carry +$79, mark to market -$74 (rate change about -$10, price vs the market then about -$64), total about +$5, identity exact.0xea88: PT lots from its July/August purchases if the router history explains the 16,327.11 PT held on 2026-08-24; otherwise the stand-in, flagged. Its readings 08-24..08-31 still have no factor in the series (known, not backfilled by decision), so those intervals stay withheld.0xf629: one lot paid in USDT, struck at the USDT paid converted at the two coins' bars at the fill (review S3: one striking rule for every stamped market trade), rate ~10.48%.