Skip to content

PRD: Revamp PnL module — cash-flow lens with Realised / Unrealised / Total split #12

@heyitsStylez

Description

@heyitsStylez

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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).
  6. 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.
  7. 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.
  8. 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).
  9. 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.
  10. 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).
  11. 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.
  12. 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.
  13. 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.
  14. 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.
  15. 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.
  16. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions