Plan: entry basis + dislocation P&L on portfolio carry trades
Show, per carry-trade position in the signed-in /portfolio view, the basis the position was entered at and the dislocation P&L since entry, so the holder can see whether secondary-market dislocations moved for or against them between entrance and (eventual) unwind. Today the Basis column shows only the current market-vs-redemption gap, which re-marks on every sync; it says nothing about whether that gap widened or narrowed since the trade was put on.
No schema change, no refresher change, no backfill: everything derives at read time from data the flow ledger already stores.
0. Current state (grounded, not assumed)
- Current basis (shipped). The carry section's Basis column is
positionBasis(p) = p.valueMarket − p.valueRedemptionover the fused carry group (src/components/portfolio/signed-in-model.ts:310), rendered byBasisCell(src/components/portfolio/PortfolioDashboard.tsx:516) with theBASIS_COL_TIPtooltip. Both marks come from the latest snapshot / JIT live read, so the figure moves on every sync. - Flows are already valued in both marks at their own block.
portfolio_flow_eventsstoresvalue_marketandvalue_redemptionper flow (scripts/sql/043-portfolio.sql:104), written by the WS4 scan / WS5 backfill (src/lib/portfolio/flows.ts, header: "a flow is valued at ITS flow block in both marks", M9: a failed read leaves the mark NULL, never zero). - Sign conventions exist.
signedFlowValue(src/lib/portfolio/pnl.ts:302) signs a flow's contribution to book equity: deposit/transfer_in/repay+, withdraw/transfer_out/borrow−, liquidation0. M4 exclusion of liquidations and their same-tx seizure mechanics lives inliquidationTxHashes/isLiquidationMechanic(pnl.ts:338/351, currently module-private). - The entry-derivation pattern exists.
src/lib/portfolio/pt-basis.ts(M11) derives a PT position's entry implied APY from acquisition fills, skips unvalued fills (never assumes par), and uses a synthetic opening-balance fill flaggedbasisSyntheticfor positions predating the data window.applyPtRedemptionMarks(src/lib/portfolio/assemble.ts:258) anchors the PT REDEMPTION mark (M12) at those entry fills. - Per-leg rows flow through
PositionRow(src/lib/portfolio/api-types.ts:157), built ingetPositions(src/lib/portfolio/api-data.ts:587); the client fuses a carry's legs into oneDisplayPosition(signed-in-model.ts toDisplayPositions) and the expanded row rendersLegDetail(PortfolioDashboard.tsx:538). - PT legs are special already. A PT leg's
accrualis'pt'(legSnapshotsFromRows), and its redemption mark is the M12 pull-to-par curve anchored at the entry implied yield, so a PT leg's current wedge is already "dislocation since entry" by construction and mechanically converges to zero at maturity.
1. Semantics (the exact math)
All figures are in the group's book unit, computed per leg server-side and summed per fused carry group client-side (same shape as every other PositionRow figure).
1.1 Per-leg entered basis
For one leg (one positionKey):
signedFlowBasis(f) = signedFlowValue(f.kind, f.valueMarket − f.valueRedemption)
= sign(f.kind) × (f.valueMarket − f.valueRedemption)
legEnteredBasis = openingBasis + Σ signedFlowBasis(f) over selected flowsSelected flows = the leg's ledger flows minus M4 exclusions (kind liquidation, and Aave/Spark same-tx seizure mechanics per isLiquidationMechanic).
openingBasis fires only when the leg's birth predates the ledger (the same "unexplained birth" notion as pnl.ts:255 and the PT opening scan in assemble.ts:287): if the leg has no flow with f.ts <= firstSnapshotTs, then
openingBasis = sideSign × (valueMarket − valueRedemption) at the opening snapshot
sideSign = −1 for a ':debt' leg, +1 otherwisewhere the opening snapshot is the earliest snapshot of the leg with both marks present (the same sliding scan applyPtRedemptionMarks uses). When an opening contributes, the result is flagged synthetic (reconstructed from tracking start, not observed fills), mirroring basisSynthetic in M11.
1.2 Honesty flags (M9)
- A selected flow with either mark NULL contributes nothing and sets
incomplete = true(the figure is partial; the UI labels it, never silently understates). - If an opening is needed but no snapshot carries both marks, the leg's entered basis is
null(+incomplete): no anchor, no number, a dash. - Never fabricate: no par assumptions, no zero-filling.
1.3 PT-family legs
A leg whose accrual === 'pt' (directly-held Pendle PT, or an Aave/Spark PT-collateral reserve) contributes {entered: 0, incomplete: false, synthetic: false} by construction: M12 anchors its redemption curve at the entry fills, so its current valueMarket − valueRedemption is already "dislocation since entry" and its entrance basis is definitionally ~0 (exact for a single fill; a quantity-weighted blend across multi-tenor fills leaves a negligible residual). Do not read PT flow marks for this: an Aave PT-collateral transfer flow's underlying is booked at the flow-block PT rate on the market side only, and special-casing to zero is both exact and documented.
1.4 Group figures (client)
For a fused carry DisplayPosition:
enteredBasis(p) = Σ legs' basisEntered (already equity-signed; null if ANY leg is null)
currentBasis(p) = p.valueMarket − p.valueRedemption (raw, NOT positionBasis's 1e-6-gated value)
dislocationPnl(p) = currentBasis(p) − enteredBasis(p) (null if enteredBasis is null)
incomplete / synthetic = OR over legsWorked check (deposit 100 units at a 1% discount, later withdraw half at a 5% discount): entered = −1 + (+2.5) = +1.5; current = −2.5; dislocation P&L = −4.0 = realized −2.0 on the exited half + unrealized −2.0 on the rest. Partial unwinds realize dislocation correctly with no extra bookkeeping.
Debt-side check (borrow USDe at market 0.99 / redemption 1.00): borrow signs negative, so entered = −0.99 − (−1.00) = +0.01, matching the debt leg's +0.01 contribution to current basis; if USDe recovers to par before repay the delta reads −0.01 (the discount you borrowed into evaporated). Correct economics on both sides for free, from the existing sign convention.
1.5 Non-properties (do not "simplify" to these)
- Do not derive dislocation P&L as
yieldMarket − yieldRedemption. The identity holds only for'pt'/'value'-accrual legs (where the yield invariant ties yield to Δvalue − flows); an'index'leg's attribution uses the composed-index ratio and deliberately ignores basis moves, so the subtraction is wrong exactly where it matters most (Aave/Spark/Fluid carries). - Entered basis is stable across syncs (it derives from the immutable flow ledger); only the current-basis side of the delta re-marks. That stability is the point of the feature and a good manual-QA check.
2. Implementation
2.1 src/lib/portfolio/entry-basis.ts (new, pure)
Same shape as pt-basis.ts: no DB, no RPC, adversarially unit-tested.
export type LegEnteredBasis = {
entered: number | null; // book units; null = no anchor (M9)
incomplete: boolean; // some selected flow lacked a mark; figure is partial
synthetic: boolean; // opening-balance anchored (position predates tracking)
};
export type EntryBasisSnap = { ts: number; valueMarket: number | null; valueRedemption: number | null };
export function deriveLegEnteredBasis(
snaps: EntryBasisSnap[], // the leg's snapshots, ascending ts
flows: RawFlow[], // the leg's flows, M4-prefiltered by the caller
opts: { side: "asset" | "debt"; isPt: boolean },
): LegEnteredBasis;Logic exactly as §1.1–§1.3. Reuse signedFlowValue from pnl.ts.
2.2 src/lib/portfolio/pnl.ts
Export the two M4 helpers (liquidationTxHashes, isLiquidationMechanic) so api-data.ts can prefilter flows for the basis derivation with the identical exclusion netting already uses. No behavior change.
2.3 src/lib/portfolio/api-types.ts
Three new required fields on PositionRow:
basisEntered: number | null; // net (market − redemption) this leg's entries brought in, book units
basisEnteredIncomplete: boolean;
basisEnteredSynthetic: boolean;2.4 src/lib/portfolio/api-data.ts (getPositions)
Inside the per-book loop (which already has bookLegs and bookFlows):
const liqTxs = liquidationTxHashes(ctx.flows)(all flows, so cross-leg same-tx mechanics are caught), thenflowsForBasis = bookFlows.filter(f => f.kind !== "liquidation" && !isLiquidationMechanic(f, liqTxs)).- Group
bookLegsandflowsForBasisbypositionKey(twoMaps). - Per row:
deriveLegEnteredBasis(snapsOfKey, flowsOfKey, { side, isPt: legsOfKey[0].accrual === "pt" })and spread the three fields onto thePositionRow. NotebookLegsareLegSnapshots post-applyPtRedemptionMarks, so a non-PT opening snapshot carries its real stored marks. The EURC idle path setsbasisEntered: null, …Incomplete: false, …Synthetic: false.
Nothing else: getPositionsAll spreads rows and inherits the fields; the Fluid venue works uniformly (its flows land in the same ledger with both marks; its liquidation prefix-flows are excluded as M4 like everywhere else).
2.5 src/components/portfolio/signed-in-model.ts (pure view model)
export function positionRawBasis(p: DisplayPosition): number; // valueMarket − valueRedemption, ungated
export function positionEnteredBasis(p: DisplayPosition): { entered: number | null; incomplete: boolean; synthetic: boolean };
export function positionBasisDelta(p: DisplayPosition): number | null; // rawBasis − entered, null-propagatingpositionEnteredBasis sums legs[].basisEntered (already equity-signed by signedFlowValue; a debt leg needs no re-signing), null-poisons on any null leg, ORs the flags. positionBasis (the 1e-6-gated display value) stays untouched for the column.
2.6 src/components/portfolio/PortfolioDashboard.tsx (UI)
Memo-over-dashboard: the Basis column stays exactly as is; the new figures live in the expanded panel (depth on demand). Changes:
LegDetailtakes theDisplayPosition(it currently takes onlylegs).For
category === "carry_trade", render a Basis attribution strip above the leg rows, monotabular-nums, three figures on one line:BASIS ATTRIBUTION At entry −$412.30 Now −$118.10 Dislocation P&L +$294.20- Delta colored green/red (it is a P&L; matches the yield columns). Entry and Now stay muted (they are levels, not P&L).
~prefix on "At entry" and the delta whenincomplete, with a footnote line: "Some flows lack a valuation; entry basis is partial."- Footnote when
synthetic: "Position predates tracking; entry basis is reconstructed from the first snapshot, not observed fills." - Dash + "Entry basis unavailable" when entered is null. Respect
rendersZeroso a dust figure never prints a signed zero (M9).
New
ENTRY_BASIS_TIPon the strip label (no em-dashes, TradFi first):Basis at entry is the market-versus-redemption gap your fills carried, valued at each flow's own block. Dislocation P&L is today's basis minus that: what secondary-market moves since entry would add to or subtract from an exit at market prices. It is realized only by unwinding on the secondary market; redeeming at par forgoes it. Yield earned is unaffected: this isolates price dislocation from carry. Fixed-term (PT) legs enter at zero by construction; their basis column already measures the move since entry.
Append one sentence to
BASIS_COL_TIP: "Expand the position to see the basis at entry and the dislocation P&L since."
No new chart (removing one would not degrade a decision; a three-number strip answers the question).
3. Edge cases (decided here, not during coding)
| Case | Behavior |
|---|---|
| Multiple entry fills | Accumulate per flow; no averaging needed (sums, not rates). |
| Partial unwind | Exit flow's signed basis realizes the dislocation on the exited slice (worked example §1.4). |
| Debt legs | signedFlowValue signs handle it; borrow-at-discount enters positive basis, symmetric with the current-basis netting. |
| Liquidation | Excluded (M4), including same-tx seizure mechanics; a liquidation is a realized loss, never an "exit at market". |
| Transfer between two tracked wallets | Receiver's transfer_in enters at the transfer-block basis: the receiving wallet's entrance is the transfer, which is the honest per-wallet reading. |
| PT legs (pendle, Aave/Spark PT collateral) | Entered ≡ 0 (M12 anchors redemption at entry); §1.3. |
| Position predates tracking | Opening-snapshot anchor, flagged synthetic ("since tracking start"). |
| NULL flow marks | Skip + incomplete; NULL opening marks → null entered (M9, §1.2). |
| Aave/Spark flow values bundle accrued interest | Same known approximation as flow netting (flows.ts header NB); perturbs both marks together, so the basis difference is noise-level. Document in docs/metrics.md. |
| Sync clicked repeatedly | Entered basis does not move (ledger-derived); only "Now" re-marks. |
| All-wallets aggregate | Rows are per-wallet already; fusion happens per wallet card, so no cross-wallet merge questions arise. |
4. Tests
src/lib/portfolio/entry-basis.test.ts(new, mirrorspt-basis.test.tsrigor): single deposit at discount; borrow-side sign; multi-fill accumulation; partial-withdraw realization (the §1.4 worked example, asserting entered+1.5and implied delta−4.0); liquidation + same-tx mechanics excluded; NULL-mark flow skipped withincomplete; opening-balance synthetic (asset and debt side); flow-explained birth adds no opening;isPtshort-circuit.signed-in-model.test.ts: fused group sums legs (collateral + debt); any-null poisons;positionBasisDelta= raw basis − entered; flags OR.PortfolioDashboard.test.tsx: expanded carry renders the Basis attribution strip;~and footnotes on incomplete/synthetic; dash on null.api-routes.test.ts/assemble.test.tsfixtures: extend any pinnedPositionRowshapes with the three fields.
npx tsc --noEmit clean; npm test (node test runner) green.
5. Docs to keep in sync
docs/metrics.md: new subsection under the portfolio methodology — entered basis, dislocation P&L, the M4/M9/M11/M12 cross-references, the PT zero-by-construction rule, and the Aave accrued-interest caveat.docs/portfolio.md: the new expanded-panel strip and its tooltip copy.- Append an entry to
docs/plans/portfolio-execution-log.mdwhen shipped.
6. Out of scope (deliberate)
- A headline "portfolio dislocation P&L" tile (per-position first; aggregate is a follow-up once the per-position number has been validated on staging).
- A basis-over-time chart series in
/api/portfolio/history. - Surfacing the M12
basisSyntheticflag on PT rows (plumbing exists insideapplyPtRedemptionMarksbut is not exposed; fold into a later pass). - Any change to
token_basis, the carries page, or the oracle panels — this is portfolio-only.
7. Rollout
Feature branch claude/entrance-basis-portfolio-2ek2aj → PR into staging → validate on staging.creddit.xyz with a wallet holding (a) a live wstETH or sUSDe carry, (b) a PT loop, (c) a pre-tracking position: confirm the entry figure is stable across two syncs while "Now" re-marks, the PT strip shows entry 0, and the pre-tracking position is labeled reconstructed. Then the usual staging → main release PR (minor version bump: feature). No migration, so no migrate.sh step and nothing to gate on prod.