Skip to content

built Built. This is a decision record, not documentation.

What is still current: Entry basis and dislocation P&L are still published per carry position, derived at read time from the dual-marked flows exactly as this plan argues. The v1 module and the column wiring it names are gone.

Landed: v0.2x; rebuilt on the current engine as src/lib/portfolio/v2/entry-basis.ts

Header updated 2026-09-14. The body below is frozen history. All plans.

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.valueRedemption over the fused carry group (src/components/portfolio/signed-in-model.ts:310), rendered by BasisCell (src/components/portfolio/PortfolioDashboard.tsx:516) with the BASIS_COL_TIP tooltip. 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_events stores value_market and value_redemption per 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 −, liquidation 0. M4 exclusion of liquidations and their same-tx seizure mechanics lives in liquidationTxHashes / 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 flagged basisSynthetic for 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 in getPositions (src/lib/portfolio/api-data.ts:587); the client fuses a carry's legs into one DisplayPosition (signed-in-model.ts toDisplayPositions) and the expanded row renders LegDetail (PortfolioDashboard.tsx:538).
  • PT legs are special already. A PT leg's accrual is '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 flows

Selected 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 otherwise

where 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 legs

Worked 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.

ts
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:

ts
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):

  1. const liqTxs = liquidationTxHashes(ctx.flows) (all flows, so cross-leg same-tx mechanics are caught), then flowsForBasis = bookFlows.filter(f => f.kind !== "liquidation" && !isLiquidationMechanic(f, liqTxs)).
  2. Group bookLegs and flowsForBasis by positionKey (two Maps).
  3. Per row: deriveLegEnteredBasis(snapsOfKey, flowsOfKey, { side, isPt: legsOfKey[0].accrual === "pt" }) and spread the three fields onto the PositionRow. Note bookLegs are LegSnapshots post-applyPtRedemptionMarks, so a non-PT opening snapshot carries its real stored marks. The EURC idle path sets basisEntered: 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) ​

ts
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-propagating

positionEnteredBasis 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:

  1. LegDetail takes the DisplayPosition (it currently takes only legs).

  2. For category === "carry_trade", render a Basis attribution strip above the leg rows, mono tabular-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 when incomplete, 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 rendersZero so a dust figure never prints a signed zero (M9).
  3. New ENTRY_BASIS_TIP on 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.

  4. 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) ​

CaseBehavior
Multiple entry fillsAccumulate per flow; no averaging needed (sums, not rates).
Partial unwindExit flow's signed basis realizes the dislocation on the exited slice (worked example §1.4).
Debt legssignedFlowValue signs handle it; borrow-at-discount enters positive basis, symmetric with the current-basis netting.
LiquidationExcluded (M4), including same-tx seizure mechanics; a liquidation is a realized loss, never an "exit at market".
Transfer between two tracked walletsReceiver'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 trackingOpening-snapshot anchor, flagged synthetic ("since tracking start").
NULL flow marksSkip + incomplete; NULL opening marks → null entered (M9, §1.2).
Aave/Spark flow values bundle accrued interestSame 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 repeatedlyEntered basis does not move (ledger-derived); only "Now" re-marks.
All-wallets aggregateRows 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, mirrors pt-basis.test.ts rigor): single deposit at discount; borrow-side sign; multi-fill accumulation; partial-withdraw realization (the §1.4 worked example, asserting entered +1.5 and implied delta −4.0); liquidation + same-tx mechanics excluded; NULL-mark flow skipped with incomplete; opening-balance synthetic (asset and debt side); flow-explained birth adds no opening; isPt short-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.ts fixtures: extend any pinned PositionRow shapes 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.md when 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 basisSynthetic flag on PT rows (plumbing exists inside applyPtRedemptionMarks but 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.

Private documentation. creddit.xyz