Problem Statement
The "Net P&L" card on the Premium P&L tab and the cumulative-P&L hero sparkline both compute P&L as totalPremium − assignmentNotional + callAwayNotional. This formula is only correct for fully-rotated wheels. While a lot is open and not yet called away, the engine has subtracted the full assignment notional (e.g. −$60k for a 1 BTC put assignment) without any offsetting credit. A single open assigned lot can swing Net P&L by tens of thousands of dollars, even though the user's actual position is just holding tokens at a known cost basis. The number doesn't match any defensible definition of P&L — it isn't realised (open lots distort it), and it isn't mark-to-market (spot price doesn't appear). The user has lost trust in the headline figure.
Solution
Replace the single Net P&L tile with three separate tiles using a cash-flow lens:
- Realised P&L — settled-events only. Premiums settle to Realised the moment an option settles; capital gains realise on call-away as
(strike − costBasis) × size. Open positions and open lots contribute zero. Only moves when a trade settles.
- Unrealised P&L — mark-to-market on currently open lots:
(spot − costBasis) × size. Premiums collected against open lots are not counted here (they're already in Realised). Updates whenever spot refreshes.
- Total P&L — Realised + Unrealised. The full picture if every open lot were sold at current spot right now.
All three tiles get hover tooltips explaining the formula and what's included. The per-lot Net Cost card also gets a tooltip clarifying it as a different lens (premium-reduced entry price) from Unrealised P&L (mark-to-market vs raw cost basis).
The cumulative-P&L hero sparkline switches to Realised-only (Unrealised is a snapshot, not a flow — it can't be back-projected without historical spot data we don't store). The Premium P&L Monthly tab's Net P&L column becomes Realised P&L; no Unrealised column on monthly.
User Stories
- As a wheel-strategy trader, I want a Realised P&L number that only moves when a trade settles, so that I can see my actual locked-in profit without it being distorted by open positions.
- As a wheel-strategy trader, I want an Unrealised P&L number that marks my open lots to market, so that I can see what my position is worth right now if I had to liquidate.
- As a wheel-strategy trader, I want a Total P&L tile, so that I have a single defensible "where do I stand" figure that's the clean sum of realised and unrealised.
- As a user hovering over each tile, I want a tooltip explaining the exact formula and what's included, so that I can defend the number to myself in six months.
- As a user filtering to a single asset, I want the new tiles to respect the asset filter, so that BTC-only view shows BTC-only P&L (consistent with every other tile on the page).
- As a user before the spot-price fetch lands or when it fails, I want Unrealised to show a dash with a "spot unavailable" sub-line for the affected asset, so that I'm not shown a misleading $0 or stale figure.
- As a user with multiple assets where only one has missing spot, I want the Unrealised tile to still sum the assets we do have spot for, with the missing asset called out in the sub-line, so that partial data is still useful.
- As a user looking at the cumulative-P&L hero sparkline, I want it to plot Realised P&L only and be labelled as such, so that the time-series semantics are unambiguous (Unrealised can't be plotted historically without lying).
- As a user looking at the Premium P&L Monthly tab, I want the Net P&L column renamed to Realised P&L, so that monthly figures are consistent with the new model — Unrealised is a snapshot, not a flow.
- As a user with a HOLDING-originated lot that gets called away, I want the call-away capital gain to count toward Realised P&L, so that spot-acquired lots are treated symmetrically with assigned lots (the current code's
assignedLotNums special case was a workaround for the broken formula and goes away).
- As a user buying back a CLOSED CALL on Hypersurface for more than the premium I collected, I want Realised P&L to take the negative hit cleanly, so that the model handles loss-making early closes without a special case.
- As a user with an open option that hasn't settled yet, I want it to contribute zero to Realised P&L, so that "unsettled credit" isn't booked as profit before the option's outcome is known.
- As a user looking at a per-lot Net Cost figure, I want a tooltip explaining how it differs from Unrealised P&L, so that I understand why the two lenses give different numbers and which to use when.
- As a maintainer, I want the PnL math extracted into a pure, dual-exported module so that it can be exhaustively unit-tested in Node.
- As a maintainer, I want the new tiles unit-tested at the rendered-DOM level (jsdom), so that regressions in the display layer (asset filter, missing spot, formatting) are caught by CI.
- As a maintainer, I want the domain glossary (
CONTEXT.md), the project memory (CLAUDE.md), and a new ADR to reflect the cash-flow lens decision, so that future contributors don't reintroduce the old formula.
Implementation Decisions
- New deep module: PnL calculator. A new pure function module (
05b-pnl.js) exposing computePnl(lots, trades, livePrices, assetFilter) → { realised, unrealised, total, breakdown }. Encapsulates the entire cash-flow lens. Dual-exported (browser global + Node module.exports) so it's exercisable from node --test. Consumed by compute() and the chart-rendering code.
- Cash-flow lens, locked. Premiums settle to Realised the moment the option settles. Open lots mark-to-market against raw
costBasis, never against netCost. Net Cost remains a per-lot view, not a tile contributor.
- Realised formula.
Σ(net premiums of all settled options) + Σ over CALLED events of (strike − costBasis) × calledSize. Open options contribute zero. CLOSED CALL with closeCost > premium is allowed to go negative — no special case.
- Unrealised formula.
Σ over open lots of (spot − costBasis) × size. HOLDING-originated and ASSIGNED-originated lots treated identically.
- Lot Engine extension.
lotEngine either exposes realisedCapitalGains per CALLED event or the new pnl module derives it from tradeAccounting + lots. Existing invariants (assigned-PUT premium credit to lotPremiums, etc.) untouched.
- Special case removed. The
assignedLotNums filter in the Premium P&L tab (which excluded HOLDING-originated call-aways from credit) was a workaround for the broken formula. Under the cash-flow lens it disappears — call-away capital gain is (strike − costBasis) × size regardless of how the lot was opened.
- Spot missing fallback. Unrealised shows
— with "spot unavailable for {asset}" sub-line. If only some assets are missing, sum the rest and call out the excluded ones in the sub-line. Never fall back to $0 or stale cached spot.
- Asset filter. All three tiles respect
sAsset, matching every other tile on the page.
- Tooltips. Hover tooltips on all three new tiles plus the per-lot Net Cost card. Use existing tooltip pattern if one exists in the codebase; otherwise native
title attribute or a small CSS-only popover.
- Cumulative hero sparkline. Series switches to Realised P&L. Label updates from "Net P&L" to "Realised P&L." Empty-state copy ("No assignment losses — Net P&L equals Premium Income") updated to match.
- Premium P&L Monthly tab. Net P&L column renamed to Realised P&L. No Unrealised column added (snapshot, not flow).
- Other tiles untouched. Total Premium Collected, Total Notional, Portfolio APR remain as-is.
portfolioPnl deprecated as a headline. The current lotEngine.portfolioPnl field is no longer the user-facing P&L number. It survives in the engine for now but is marked internal-only in CONTEXT.md.
Testing Decisions
Good tests assert on external behaviour (input trades + spot → tile values, DOM output) rather than internal state of the calculator.
- Unit tests on the PnL calculator (
test/unit/pnl.test.js, new). Exhaustive event-table coverage:
- PUT EXPIRED → Realised += netPrem
- PUT ASSIGNED → Realised += netPrem; opens lot
- CALL EXPIRED on open lot → Realised += netPrem
- CALL CALLED → Realised += netPrem + (strike − costBasis) × calledSize; lot reduces/closes
- CALL CLOSED → Realised += netPrem (already net of closeCost); lot stays open
- CLOSED CALL with
closeCost > premium → Realised takes negative hit
- HOLDING called away → capital gain counted (no special case)
- Open option contributes 0
- Partial call-away on multi-size lot → only
calledSize realises
- Missing spot for one asset → Unrealised excludes it, sums others
- Asset filter applied → only matching trades/lots counted
- Unit tests at the rendered-DOM level (
test/integration/pnl-tiles.test.js, new, jsdom). Seed a trade set, boot the app, assert the three tile DOM elements show the expected formatted values, including the spot-missing sub-line and the asset-filter case.
- Prior art:
test/unit/lot-engine.test.js and test/unit/compute.test.js for pure-function unit tests using the dual-export pattern. test/integration/*.test.js for jsdom integration tests using setupJsdom(). The new tests follow the same conventions.
Out of Scope
- Historical Unrealised P&L. Spot price isn't stored historically; we won't retrofit a price-history feed in this PRD.
- Tax-lot accounting (FIFO/LIFO/specific identification). Lots are tracked per-wheel, not per-tax-lot.
- Multi-currency P&L (e.g. P&L denominated in BTC instead of USD).
- Persisting realised events to a separate ledger. Realised is derived from
trades on every render — no new storage.
- Backfilling or migrating any existing localStorage data —
trades[] shape is unchanged.
- Changes to
portfolioPnl callers beyond the chart code; the field remains in the engine output.
Further Notes
- All work happens on a worktree branch (
pnl-revamp or similar) so the user can preview the new tiles against main before merging.
- Documentation updates in scope:
CONTEXT.md (new entries for Realised / Unrealised / Total P&L; mark portfolioPnl as internal); CLAUDE.md file-map row for 07-render-charts.js; new ADR docs/adr/0001-pnl-cash-flow-lens.md capturing the trade-off between cash-flow / net-cost / hybrid lenses and the reasoning for picking cash-flow.
- ADR is warranted: hard to reverse (users anchor on the new numbers), surprising without context (why isn't open premium counted in Realised?), and a real trade-off (three viable lenses considered).
- Engine extension vs derive-in-pnl-module is left to implementation discretion — both preserve pure-function semantics.
Problem Statement
The "Net P&L" card on the Premium P&L tab and the cumulative-P&L hero sparkline both compute P&L as
totalPremium − assignmentNotional + callAwayNotional. This formula is only correct for fully-rotated wheels. While a lot is open and not yet called away, the engine has subtracted the full assignment notional (e.g. −$60k for a 1 BTC put assignment) without any offsetting credit. A single open assigned lot can swing Net P&L by tens of thousands of dollars, even though the user's actual position is just holding tokens at a known cost basis. The number doesn't match any defensible definition of P&L — it isn't realised (open lots distort it), and it isn't mark-to-market (spot price doesn't appear). The user has lost trust in the headline figure.Solution
Replace the single Net P&L tile with three separate tiles using a cash-flow lens:
(strike − costBasis) × size. Open positions and open lots contribute zero. Only moves when a trade settles.(spot − costBasis) × size. Premiums collected against open lots are not counted here (they're already in Realised). Updates whenever spot refreshes.All three tiles get hover tooltips explaining the formula and what's included. The per-lot Net Cost card also gets a tooltip clarifying it as a different lens (premium-reduced entry price) from Unrealised P&L (mark-to-market vs raw cost basis).
The cumulative-P&L hero sparkline switches to Realised-only (Unrealised is a snapshot, not a flow — it can't be back-projected without historical spot data we don't store). The Premium P&L Monthly tab's Net P&L column becomes Realised P&L; no Unrealised column on monthly.
User Stories
assignedLotNumsspecial case was a workaround for the broken formula and goes away).CONTEXT.md), the project memory (CLAUDE.md), and a new ADR to reflect the cash-flow lens decision, so that future contributors don't reintroduce the old formula.Implementation Decisions
05b-pnl.js) exposingcomputePnl(lots, trades, livePrices, assetFilter) → { realised, unrealised, total, breakdown }. Encapsulates the entire cash-flow lens. Dual-exported (browser global + Nodemodule.exports) so it's exercisable fromnode --test. Consumed bycompute()and the chart-rendering code.costBasis, never againstnetCost. Net Cost remains a per-lot view, not a tile contributor.Σ(net premiums of all settled options) + Σ over CALLED events of (strike − costBasis) × calledSize. Open options contribute zero. CLOSED CALL withcloseCost > premiumis allowed to go negative — no special case.Σ over open lots of (spot − costBasis) × size. HOLDING-originated and ASSIGNED-originated lots treated identically.lotEngineeither exposesrealisedCapitalGainsper CALLED event or the new pnl module derives it fromtradeAccounting+lots. Existing invariants (assigned-PUT premium credit tolotPremiums, etc.) untouched.assignedLotNumsfilter in the Premium P&L tab (which excluded HOLDING-originated call-aways from credit) was a workaround for the broken formula. Under the cash-flow lens it disappears — call-away capital gain is(strike − costBasis) × sizeregardless of how the lot was opened.—with "spot unavailable for {asset}" sub-line. If only some assets are missing, sum the rest and call out the excluded ones in the sub-line. Never fall back to $0 or stale cached spot.sAsset, matching every other tile on the page.titleattribute or a small CSS-only popover.portfolioPnldeprecated as a headline. The currentlotEngine.portfolioPnlfield is no longer the user-facing P&L number. It survives in the engine for now but is marked internal-only inCONTEXT.md.Testing Decisions
Good tests assert on external behaviour (input trades + spot → tile values, DOM output) rather than internal state of the calculator.
test/unit/pnl.test.js, new). Exhaustive event-table coverage:closeCost > premium→ Realised takes negative hitcalledSizerealisestest/integration/pnl-tiles.test.js, new, jsdom). Seed a trade set, boot the app, assert the three tile DOM elements show the expected formatted values, including the spot-missing sub-line and the asset-filter case.test/unit/lot-engine.test.jsandtest/unit/compute.test.jsfor pure-function unit tests using the dual-export pattern.test/integration/*.test.jsfor jsdom integration tests usingsetupJsdom(). The new tests follow the same conventions.Out of Scope
tradeson every render — no new storage.trades[]shape is unchanged.portfolioPnlcallers beyond the chart code; the field remains in the engine output.Further Notes
pnl-revampor similar) so the user can preview the new tiles againstmainbefore merging.CONTEXT.md(new entries for Realised / Unrealised / Total P&L; markportfolioPnlas internal);CLAUDE.mdfile-map row for07-render-charts.js; new ADRdocs/adr/0001-pnl-cash-flow-lens.mdcapturing the trade-off between cash-flow / net-cost / hybrid lenses and the reasoning for picking cash-flow.