diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index ef6af00..4cdf788 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -396,6 +396,116 @@ renders from the live engine. The single-file distributable stays self-contained tooltips; CSV import/export kept as a second actions row (mock omitted them); Bond not built (decision). +### D-028 — Deleveraging on a split-allocation book (per-leg plans) +**Date:** 2026-06-30 +**Status: IMPLEMENTED (2026-06-30)** — top-level scope is per-leg; all four sub-decisions +settled. Sub-decision (1) — engine build depth — was put to the owner and DECIDED as +option (a): full combined branch with per-leg embedded-gain pools. Sub-decisions (2)–(4) +shipped as the recorded proposed defaults. Build: `SplitAllocation` gains +`coreDeleverage?`/`overlayDeleverage?`; `deleverage.ts` adds `resolveLegDeleveragePlan` + +`buildDeleverageSchedule` (single-strategy path unchanged); `core.ts` adds a combined +split+deleverage branch that collateral-weight-blends each leg's per-year +rate/financing/extension and runs a SEPARATE embedded-gain pool per leg (book pre-existing +gain assigned to the overlay leg first — the appreciated-stock sleeve — capped at its +market value, remainder to core). Book-level deleverage outputs (extensionFraction, +deleverageGain*/Tax, financingSaved) are the per-leg sums, so the existing ResultsTable +"Deleverage" column group (both orientations) and Excel export light up in split mode with +no schema change (data-driven on `extensionFraction < 1`). Per-leg breakout COLUMNS shipped +as a follow-up in the same PR: `YearResult.coreDeleverageGain`/`overlayDeleverageGain` +(defined only in split mode, summing to `deleverageGainRealized`) surface as conditional +"Core Unwind" / "Overlay Unwind" columns in both ResultsTable orientations + Excel, each +with a `popupContent.ts` entry. (Fixed a pre-existing latent bug found while there: the +ResultsTable "Start" row emitted 3 placeholder cells for the 5-column expanded Deleverage +group — now 5 — so the row aligned raggedly when the group was expanded.) CSV +round-trips `splitAllocation.coreDeleverage.*`/`.overlayDeleverage.*`. Workspace rail: the +single plan editor becomes two per-leg target selectors in split mode; the two "plan +ignored" warning chips are removed (a softer note remains only when a stray top-level plan +is set with split on). Sensitivity grid unchanged (still out, D-013/D-016). New tests: +split×deleverage equivalence (two identical legs == single-strategy), overlay-only +collateral-weighted extension, independent per-leg schedules, per-leg pools (overlay basis +raises overlay unwind gain), endogenous-netting invariant, financing-glide, CSV round-trip; +suite 408 → 417. Browser-verified: enabling the overlay leg plan moved the headline +$2,686,020 → $1,993,074 with no console errors. + +**Context:** Split allocation and deleveraging are currently mutually exclusive: split +wins, the plan is dropped with a warning chip (`WorkspaceTab.tsx:1140-1152`, and the +results-pane twin), `resolveDeleveragePlan` returns null when split is on +(`deleverage.ts:78`), and `core.ts:209` forces `dlvPlan = null` in split mode. Owner +wants each split leg (core + overlay) to carry and glide its OWN deleverage plan. + +**DECIDED (owner, scope):** **Per-leg deleverage plans (Option B).** Each leg gets its +own `startYear` / `durationYears` / `target` and its own D-017 knobs, gliding +independently. Chosen explicitly over "overlay-leg-only" (smallest build) and +"whole-blended-book single plan" (simplest wiring). Constraints carried forward: D-016/ +D-017 attribution (unwind gains are ENDOGENOUS — netted with strategy flows, harvest +first then CFs per §1211, charged against `taxSavings`; `deleverageTax` stays a reporting +decomposition, not a second subtraction); D-013/D-016 keep deleverage OUT of the +sensitivity grid (cells stay comparable — `sensitivity.ts` carries no deleverage path, +confirmed); audit-complete table directive applies to every new per-year output; D-002 +(plans off by default). + +A per-leg model still forces four sub-decisions. (1) is the genuine fork and is being +put to the owner; (2)–(4) have a mechanically-defensible default recorded here. + +**Sub-decision 1 — Engine build depth (FLAGGED FOR OWNER).** +*Context:* `core.ts` is today a hard `if isSplit {} else if dlvYear {} else {}` +(`core.ts:209`, `:371-406`). `deleverage.ts` assumes ONE source strategy +(`plan.source = getStrategy(inputs.strategyId)`, single `sourceLongLeverage`/ +`sourceShortRatio`), and the embedded-gain pool (`core.ts:214-220`, `:380-406`) is a +single book. +- (a) **Full combined branch (recommended):** new split+deleverage branch that + collateral-weight-blends each leg's per-year rate (a leg may glide OR stay static) AND + a SEPARATE embedded-gain pool per leg, so endogenous unwind gains attribute to the + right leg. Highest effort; no rework; defensible to a CPA. `resolveDeleverageSchedule` + generalizes to take a leg as its source. +- (b) **Shared gain pool first:** per-leg rate blend now, but ONE blended embedded-gain + pool across both legs in v1. Faster; per-leg unwind-gain attribution is approximate + until a follow-up. +- (c) **Overlay-leg glides only:** smallest branch, but the core leg can't glide — + contradicts the per-leg scope just chosen, so really a staged delivery of B. +*Recommendation:* (a) — split exists for clients with a concentrated/appreciated overlay +leg whose embedded gain differs sharply from the core; a shared pool would mis-attribute +exactly the unwind cost the split was created to model, failing the CPA bar. +*DECIDED (owner, 2026-06-30):* **(a)** — full combined branch with a separate +embedded-gain pool per leg. + +**Sub-decision 2 — Plan shape / attach point.** *Proposed default:* move the plan onto +each `SplitAllocation` leg (`coreDeleverage?: DeleveragePlan` / `overlayDeleverage?: +DeleveragePlan` on the `SplitAllocation` interface, `types.ts:15-21`), keeping the +existing top-level `inputs.deleveragePlan` for single-strategy mode untouched. Preferred +over a `deleveragePlans[]` array keyed by leg: it mirrors the existing per-leg amount +fields, keeps single-mode bit-identical, and round-trips through CSV by extending the +existing `deleveragePlan.*` serializer (`csvScenario.ts:134-147`, `:373-428`) with +`splitAllocation.coreDeleverage.*` / `.overlayDeleverage.*` rows (and the Excel export +mirror). Reason: smallest schema delta that keeps single-strategy mode and the existing +round-trip tests unchanged. + +**Sub-decision 3 — D-017 defaults per leg.** *Proposed default:* the D-017 +defensible-middle bundle (seasoned-LT unwind character with the `startYear > 2` ST/LT +split, 0% short-cover, pro-rata lot selection haircut 1.0) applies INDEPENDENTLY per leg, +each leg's own `startYear` driving its own seasoned/unseasoned character — exactly the +per-leg logic `resolveDeleveragePlan` already encodes (`deleverage.ts:105-107`), just +evaluated per leg. All three knobs stay overridable per leg. Reason: D-017 is already +position-scoped, not book-scoped; per-leg is the faithful generalization, not a new +philosophy. + +**Sub-decision 4 — Surface area / audit-complete.** *Proposed default:* the existing +"Deleverage" ResultsTable column group (Extension %, Deleverage Gain Realized, Tax on +Unwind, Financing Saved) plus its `popupContent.ts` entries now render in split mode with +PER-LEG attribution, in BOTH orientations (rows and transposed years-as-columns), per the +audit-complete directive. The two "plan ignored" warning chips +(`WorkspaceTab.tsx:1140-1152` + twin) are replaced by real per-leg target selectors in +the rail's Deleveraging group. Sensitivity grid stays OUT (D-013/D-016). Reason: this is +the standing audit-complete requirement applied to a new per-year output, not a new +decision. + +**Implications if owner confirms (a)+defaults:** engine work is in-scope now +(`core.ts` combined branch, `resolveDeleverageSchedule` takes a leg source, per-leg +embedded-gain pools); CSV + Excel extend with the two per-leg plan blocks; ResultsTable + +popups gain per-leg deleverage attribution; the conflict chips become target selectors. +No change to single-strategy mode, to D-016/D-017 attribution, or to the sensitivity +grid. + --- ## Pending decision queue (next batches) diff --git a/src/ResultsTable.tsx b/src/ResultsTable.tsx index d806ce8..e941144 100644 --- a/src/ResultsTable.tsx +++ b/src/ResultsTable.tsx @@ -119,6 +119,9 @@ export function ResultsTable({ // Deleverage column group (D-016): only when a plan actually unwound something. const hasDeleverage = data.some(y => y.extensionFraction < 1); + // Per-leg unwind attribution columns (D-028): only in split mode, where the + // engine populates core/overlay unwind gains. + const hasPerLegDeleverage = data.some(y => y.overlayDeleverageGain !== undefined); // Toggle all details const handleToggleAll = () => { @@ -173,7 +176,7 @@ export function ResultsTable({ // Deleverage group (only when a plan is active) if (hasDeleverage) { cols += 1; // Extension % headline - if (expandDeleverage) cols += 5; // Unwind Gain, ST/LT split, Unwind Tax, Fin. Saved + if (expandDeleverage) cols += 5 + (hasPerLegDeleverage ? 2 : 0); // Unwind Gain, ST/LT split, Unwind Tax, Fin. Saved, [Core/Overlay Unwind] } cols += 1; // Tax Savings column @@ -457,6 +460,18 @@ export function ResultsTable({ Fin. Saved + {hasPerLegDeleverage && ( + <> + + Core Unwind + + + + Overlay Unwind + + + + )} )} @@ -573,9 +588,18 @@ export function ResultsTable({ {hasDeleverage && 100%} {hasDeleverage && expandDeleverage && ( <> + {/* Unwind Gain, Unwind ST, Unwind LT, Unwind Tax, Fin. Saved */} + — — — — + — + {hasPerLegDeleverage && ( + <> + — + — + + )} )} — @@ -842,6 +866,20 @@ export function ResultsTable({ ? formatCurrency(year.financingSaved) : '—'} + {hasPerLegDeleverage && ( + <> + + {(year.coreDeleverageGain ?? 0) > 0.01 + ? formatCurrency(year.coreDeleverageGain ?? 0) + : '—'} + + + {(year.overlayDeleverageGain ?? 0) > 0.01 + ? formatCurrency(year.overlayDeleverageGain ?? 0) + : '—'} + + + )} )} @@ -1264,6 +1302,21 @@ function TransposedTable({ contentKey: 'col-financing-saved', cell: y => moneyOrDash(y.financingSaved), }, + // Per-leg unwind attribution (D-028) — only in split mode. + ...(data.some(y => y.overlayDeleverageGain !== undefined) + ? [ + { + label: 'Core Unwind Gain', + contentKey: 'col-deleverage-core-gain', + cell: (y: YearResult) => moneyOrDash(y.coreDeleverageGain ?? 0), + }, + { + label: 'Overlay Unwind Gain', + contentKey: 'col-deleverage-overlay-gain', + cell: (y: YearResult) => moneyOrDash(y.overlayDeleverageGain ?? 0), + }, + ] + : []), ], }); } diff --git a/src/calculations/core.ts b/src/calculations/core.ts index 3ab7b26..6fc5c57 100644 --- a/src/calculations/core.ts +++ b/src/calculations/core.ts @@ -42,7 +42,14 @@ import { ResolvedAllocation, ResolvedLeg, } from './splitAllocation'; -import { resolveDeleveragePlan, resolveDeleverageSchedule } from './deleverage'; +import { + resolveDeleveragePlan, + resolveDeleverageSchedule, + resolveLegDeleveragePlan, + buildDeleverageSchedule, + ResolvedDeleveragePlan, + DeleverageYearSchedule, +} from './deleverage'; /** * Optional per-year overrides used by the split-allocation and deleverage @@ -203,11 +210,32 @@ export function calculateWithOverrides( // and stopping early if no income is consuming it). const hardCapYears = Math.max(effectiveProjectionYears, 40); - // Deleveraging (D-016/D-017): resolve the per-year glide schedule once. - // Null when the plan is disabled/invalid or split allocation is enabled - // (split wins in v1; the UI shows a "plan ignored" warning). + // Deleveraging (D-016/D-017): resolve the single-strategy glide schedule + // once. Null when the plan is disabled/invalid or split allocation is + // enabled (split runs its own per-leg plans — D-028). const dlvPlan = allocation.isSplit ? null : resolveDeleveragePlan(inputs); const dlvSchedule = dlvPlan ? resolveDeleverageSchedule(inputs, settings, hardCapYears) : null; + + // Per-leg deleveraging (D-028): in split mode each leg may carry its own + // plan on `inputs.splitAllocation` (legs[0] = core, legs[1] = overlay, per + // resolveAllocation order). A leg with no plan stays fully extended. Each + // leg schedules and unwinds against its OWN embedded-gain pool. + const splitDlv = inputs.splitAllocation; + const legDlvPlans: (ResolvedDeleveragePlan | null)[] = allocation.isSplit + ? allocation.legs.map((leg, i) => + resolveLegDeleveragePlan( + i === 0 ? splitDlv?.coreDeleverage : splitDlv?.overlayDeleverage, + leg.strategy + ) + ) + : []; + const legDlvSchedules: (DeleverageYearSchedule[] | null)[] = legDlvPlans.map(p => + p + ? buildDeleverageSchedule(p, settings, hardCapYears, inputs.startMonth ?? 1, qfafDuration) + : null + ); + const anySplitDlv = legDlvPlans.some(p => p !== null); + // Running embedded-gain state for unwind-gain sizing (consistent with // exitTax.ts: market appreciation + pre-existing gain + Σ(harvested ST // losses − realized LT gains) − Σ prior unwind gains realized). @@ -219,6 +247,21 @@ export function calculateWithOverrides( const dlvPreExistingGain = Math.max(0, dlvInitialCollateral - dlvCostBasis); let dlvCumNetHarvest = 0; let dlvCumUnwindRealized = 0; + + // Per-leg embedded-gain pools (D-028). The book-level pre-existing gain + // (collateral − basis) is assigned to the OVERLAY leg first — the + // appreciated-stock sleeve in the split's framing — capped at its market + // value, with any remainder spilling to core, so the overlay's unwind + // realizes the concentrated gain the split exists to model. + const legInitialCollateral = allocation.legs.map(l => l.collateralAmount); + const legPreExistingGain = legInitialCollateral.map(() => 0); + if (anySplitDlv && dlvPreExistingGain > 0 && legInitialCollateral.length === 2) { + const overlayGain = Math.min(dlvPreExistingGain, legInitialCollateral[1]); + legPreExistingGain[1] = overlayGain; + legPreExistingGain[0] = dlvPreExistingGain - overlayGain; + } + const legCumNetHarvest = legInitialCollateral.map(() => 0); + const legCumUnwindRealized = legInitialCollateral.map(() => 0); // Income for extension years continues the FINAL scheduled year's income // (e.g., a retirement schedule persists), not the base input. let carryIncome = inputs.annualIncome; @@ -306,7 +349,54 @@ export function calculateWithOverrides( // Calendar-year rate blending + operating fraction (see core.ts for derivation). const opFraction = getOperatingFraction(year, inputs.startMonth ?? 1, qfafDuration); + const strategyActive = opFraction > 0; + const yearFractionForCall = strategyActive ? opFraction : 1.0; const dlvYear = dlvSchedule ? dlvSchedule[year - 1] : undefined; + + // Per-leg deleverage blend (D-028): in split mode, each leg uses its glide + // schedule's rate/financing if it has a plan, else its static calendar-year + // values; everything is collateral-weighted into the book-level overrides. + // Per-leg ST/LT rates are stashed so the embedded-gain pools below advance + // with each leg's OWN harvest (Σ per-leg = the book total by construction). + const legStRateThisYear = yearStartLegs.map(() => 0); + const legLtRateThisYear = yearStartLegs.map(() => 0); + let splitDlvSt = 0; + let splitDlvLt = 0; + let splitDlvFin = 0; + let splitDlvFinSaved = 0; + let splitDlvExtension = 0; + if (allocation.isSplit && anySplitDlv) { + const total = yearStartTotalCollateral; + for (let i = 0; i < yearStartLegs.length; i++) { + const leg = yearStartLegs[i]; + const wgt = total > 0 ? leg.collateralAmount / total : 0; + const dy = legDlvSchedules[i]?.[year - 1]; + if (dy) { + legStRateThisYear[i] = dy.stLossRate; + legLtRateThisYear[i] = dy.ltGainRate; + splitDlvSt += wgt * dy.stLossRate; + splitDlvLt += wgt * dy.ltGainRate; + splitDlvFin += wgt * dy.financingCost; + splitDlvFinSaved += leg.collateralAmount * dy.financingSavedRate * yearFractionForCall; + splitDlvExtension += wgt * dy.w; + } else { + const legSt = getCalendarYearStLossRate( + leg.strategy.id, + leg.strategy.ltGainRate, + year, + inputs.startMonth ?? 1, + qfafDuration + ); + legStRateThisYear[i] = legSt; + legLtRateThisYear[i] = leg.strategy.ltGainRate; + splitDlvSt += wgt * legSt; + splitDlvLt += wgt * leg.strategy.ltGainRate; + splitDlvFin += wgt * getEffectiveFinancingCost(leg.strategy, settings); + splitDlvExtension += wgt * 1; + } + } + } + // Deleverage years replace the rate with the source→target blend; the // dynamic-QFAF sizing below consumes the same blended rate, so dynamic // sizing self-corrects as the book delevers (fixed sizing does not — @@ -314,12 +404,14 @@ export function calculateWithOverrides( const calStLossRate = dlvYear ? dlvYear.stLossRate : allocation.isSplit - ? getBlendedCalendarYearStLossRate( - yearAllocation, - year, - inputs.startMonth ?? 1, - qfafDuration - ) + ? anySplitDlv + ? splitDlvSt + : getBlendedCalendarYearStLossRate( + yearAllocation, + year, + inputs.startMonth ?? 1, + qfafDuration + ) : getCalendarYearStLossRate( allocation.primary.strategy.id, allocation.primary.strategy.ltGainRate, @@ -344,13 +436,22 @@ export function calculateWithOverrides( effectiveQfafValue = cappedQfaf; } - const strategyActive = opFraction > 0; - const yearFractionForCall = strategyActive ? opFraction : 1.0; - // Pre-blend rates for split mode. let yearOverrides: CalculateYearOverrides | undefined; let strategyForCalc: StrategyRates; - if (allocation.isSplit) { + if (allocation.isSplit && anySplitDlv) { + // D-028: split + per-leg deleverage. Rates/financing are the + // collateral-weighted per-leg blend; extension % is the collateral- + // weighted glide weight; financing saved sums the per-leg savings. + strategyForCalc = { stLossRate: calStLossRate, ltGainRate: splitDlvLt }; + yearOverrides = { + effectiveStLossRate: calStLossRate, + ltGainRate: splitDlvLt, + financingCost: splitDlvFin, + extensionFraction: splitDlvExtension, + deleverageFinancingSaved: splitDlvFinSaved, + }; + } else if (allocation.isSplit) { const blendedLt = getBlendedLtGainRate(yearAllocation); strategyForCalc = { stLossRate: calStLossRate, ltGainRate: blendedLt }; yearOverrides = { @@ -405,6 +506,51 @@ export function calculateWithOverrides( yearOverrides.unwindLtGain = dlvPlan.unwindGainCharacter === 'lt' ? dlvLongUnwindGain : 0; } + // Per-leg unwind gains (D-028): each delevering leg realizes a pro-rata + // share of its OWN embedded-gain pool, summed into the book-level overrides + // (same endogenous netting as single mode). `legLongUnwindThisYear` records + // the long-side realization so each leg's pool can be depleted after the + // year's harvest is known. + const legLongUnwindThisYear = yearStartLegs.map(() => 0); + // Per-leg TOTAL realized unwind gain this year (long + short cover), for the + // ResultsTable per-leg attribution columns (D-028). Sums to the book + // `deleverageGainRealized` by construction. + const legTotalUnwindThisYear = yearStartLegs.map(() => 0); + if (allocation.isSplit && anySplitDlv && yearOverrides) { + let totalUnwindSt = 0; + let totalUnwindLt = 0; + for (let i = 0; i < yearStartLegs.length; i++) { + const lp = legDlvPlans[i]; + const dy = legDlvSchedules[i]?.[year - 1]; + if (!lp || !dy || dy.fracUnwoundThisYear <= 0) continue; + const legColl = yearStartLegs[i].collateralAmount; + const embeddedGain = Math.max( + 0, + legColl - + legInitialCollateral[i] + + legPreExistingGain[i] + + legCumNetHarvest[i] - + legCumUnwindRealized[i] + ); + const grossLongValue = legColl * (1 + lp.sourceLongLeverage); + const embeddedGainPct = grossLongValue > 0 ? embeddedGain / grossLongValue : 0; + const longDollarsUnwound = + dy.fracUnwoundThisYear * + Math.max(0, lp.sourceLongLeverage - lp.targetLongLeverage) * + legColl; + const legLongUnwindGain = lp.lotSelectionHaircut * embeddedGainPct * longDollarsUnwound; + legLongUnwindThisYear[i] = legLongUnwindGain; + const shortDollarsCovered = + dy.fracUnwoundThisYear * Math.max(0, lp.sourceShortRatio - lp.targetShortRatio) * legColl; + const shortCoverGain = lp.shortCoverGainPct * shortDollarsCovered; + legTotalUnwindThisYear[i] = legLongUnwindGain + shortCoverGain; + totalUnwindSt += (lp.unwindGainCharacter === 'st' ? legLongUnwindGain : 0) + shortCoverGain; + totalUnwindLt += lp.unwindGainCharacter === 'lt' ? legLongUnwindGain : 0; + } + yearOverrides.unwindStGain = totalUnwindSt; + yearOverrides.unwindLtGain = totalUnwindLt; + } + const ev = override?.gainEvent; if (ev && ev.amount > 0) { yearOverrides.eventStGain = ev.character === 'st' ? ev.amount : 0; @@ -443,12 +589,37 @@ export function calculateWithOverrides( dlvCumNetHarvest += result.stLossesHarvested - result.ltGainsRealized; dlvCumUnwindRealized += dlvLongUnwindGain; - // In split mode, recompute per-leg next-year values. + // Advance each leg's embedded-gain pool (D-028). Per-leg net harvest uses + // the leg's own ST/LT rate (Σ legs = the book harvest by construction), so + // the pools stay consistent with `result` while attributing the unwind cost + // to the right sleeve. Also surface each leg's realized unwind gain for the + // ResultsTable per-leg attribution columns (legs[0] = core, legs[1] = overlay). + if (allocation.isSplit && anySplitDlv) { + for (let i = 0; i < yearStartLegs.length; i++) { + const legColl = yearStartLegs[i].collateralAmount; + const grossStLoss = strategyActive ? legColl * legStRateThisYear[i] : 0; + const legHarvested = grossStLoss * (1 - settings.washSaleDisallowanceRate); + const legLtGain = + strategyActive && inputs.ltGainsEnabled !== false + ? legColl * legLtRateThisYear[i] * yearFractionForCall + : 0; + legCumNetHarvest[i] += legHarvested - legLtGain; + legCumUnwindRealized[i] += legLongUnwindThisYear[i]; + } + result.coreDeleverageGain = safeNumber(legTotalUnwindThisYear[0] ?? 0); + result.overlayDeleverageGain = safeNumber(legTotalUnwindThisYear[1] ?? 0); + } + + // In split mode, recompute per-leg next-year values. A delevering leg's + // financing follows its glide schedule's interpolated cost (D-028); + // static legs keep their strategy's fixed cost. if (allocation.isSplit) { const baseReturn = settings.growthEnabled ? settings.defaultAnnualReturn : 0; for (let i = 0; i < legCollateral.length; i++) { const legStrategy = yearStartLegs[i].strategy; - const legFinancing = getEffectiveFinancingCost(legStrategy, settings); + const legFinancing = + legDlvSchedules[i]?.[year - 1]?.financingCost ?? + getEffectiveFinancingCost(legStrategy, settings); const grown = legCollateral[i] * (1 + baseReturn * yearFractionForCall); legCollateral[i] = grown * (1 - legFinancing * yearFractionForCall); } diff --git a/src/calculations/deleverage.ts b/src/calculations/deleverage.ts index 7b3dc58..caf7a0a 100644 --- a/src/calculations/deleverage.ts +++ b/src/calculations/deleverage.ts @@ -1,4 +1,4 @@ -import { CalculatorInputs, AdvancedSettings } from '../types'; +import { CalculatorInputs, AdvancedSettings, DeleveragePlan } from '../types'; import { Strategy, getStrategy, getLongLeverageRatio, getShortRatio } from '../strategyData'; import { getEffectiveStLossRate } from './helpers'; import { getFinancingCostForRatios } from './financing'; @@ -68,19 +68,17 @@ export interface DeleverageYearSchedule { } /** - * Validate and resolve a plan against the inputs. Returns null when the plan - * is disabled, malformed, or out of scope: split allocation wins when both - * are enabled (v1 — the UI shows a "plan ignored" warning). + * Resolve a plan against an explicit source strategy (no inputs coupling). + * Returns null when the plan is malformed or its target can't be resolved. + * Shared by the single-strategy path (`resolveDeleveragePlan`) and the + * per-leg split path (`resolveLegDeleveragePlan`, D-028). */ -export function resolveDeleveragePlan(inputs: CalculatorInputs): ResolvedDeleveragePlan | null { - const plan = inputs.deleveragePlan; - if (!plan || !plan.enabled) return null; - if (inputs.splitAllocation?.enabled) return null; +function resolvePlanForSource( + plan: DeleveragePlan, + source: Strategy +): ResolvedDeleveragePlan | null { if (!(plan.startYear >= 1) || !(plan.durationYears >= 1)) return null; - const source = getStrategy(inputs.strategyId); - if (!source) return null; - let targetLongLeverage: number; let targetShortRatio: number; let targetLabel: string; @@ -113,6 +111,36 @@ export function resolveDeleveragePlan(inputs: CalculatorInputs): ResolvedDelever }; } +/** + * Validate and resolve the single-strategy plan against the inputs. Returns + * null when the plan is disabled, malformed, or out of scope: split allocation + * runs its own PER-LEG plans (D-028), so the top-level plan is ignored when + * split is enabled. + */ +export function resolveDeleveragePlan(inputs: CalculatorInputs): ResolvedDeleveragePlan | null { + const plan = inputs.deleveragePlan; + if (!plan || !plan.enabled) return null; + if (inputs.splitAllocation?.enabled) return null; + + const source = getStrategy(inputs.strategyId); + if (!source) return null; + + return resolvePlanForSource(plan, source); +} + +/** + * Resolve a per-leg plan in split mode (D-028). Unlike `resolveDeleveragePlan` + * there is no split guard — these plans ARE the split-mode deleverage path — + * and the source strategy is the leg's own strategy, not `inputs.strategyId`. + */ +export function resolveLegDeleveragePlan( + plan: DeleveragePlan | undefined, + source: Strategy +): ResolvedDeleveragePlan | null { + if (!plan || !plan.enabled) return null; + return resolvePlanForSource(plan, source); +} + /** * End-of-year extension weight for a plan: 1 before startYear, then a linear * glide reaching 0 at the end of year startYear + durationYears − 1. @@ -183,7 +211,22 @@ export function resolveDeleverageSchedule( ): DeleverageYearSchedule[] | null { const plan = resolveDeleveragePlan(inputs); if (!plan) return null; + return buildDeleverageSchedule(plan, settings, maxYears, startMonth, qfafDuration); +} +/** + * Build the per-year glide schedule for an already-resolved plan. Split out + * from `resolveDeleverageSchedule` (D-028) so the per-leg split path can + * schedule each leg's own resolved plan directly, without round-tripping + * through `inputs`. + */ +export function buildDeleverageSchedule( + plan: ResolvedDeleveragePlan, + settings: AdvancedSettings, + maxYears: number, + startMonth: number, + qfafDuration: number +): DeleverageYearSchedule[] { const srcLt = plan.source.ltGainRate; const tgtLt = targetLtGainRate(plan); const sourceFinancing = getFinancingCostForRatios( diff --git a/src/deleverage.test.ts b/src/deleverage.test.ts index b24be84..a8708cf 100644 --- a/src/deleverage.test.ts +++ b/src/deleverage.test.ts @@ -63,6 +63,23 @@ function plan(overrides: Partial = {}): DeleveragePlan { }; } +function splitInputs( + overrides: Partial = {}, + split: Partial = {} +): CalculatorInputs { + return createInputs({ + splitAllocation: { + enabled: true, + coreStrategyId: 'core-145-45', + coreAmount: 5000000, + overlayStrategyId: 'overlay-45-45', + overlayAmount: 5000000, + ...split, + }, + ...overrides, + }); +} + afterEach(() => { clearRateOverrides(); }); @@ -326,6 +343,191 @@ describe('unwind tax attribution (endogenous, opposite of D-012)', () => { }); }); +describe('split allocation per-leg deleverage (D-028)', () => { + it('a split with no per-leg plans is exactly the no-deleverage split', () => { + const base = calculate(splitInputs(), DEFAULT_SETTINGS); + const withTopLevel = calculate(splitInputs({ deleveragePlan: plan() }), DEFAULT_SETTINGS); + // The top-level plan stays single-strategy-only — ignored in split mode. + expect(withTopLevel.summary.totalTaxSavings).toBeCloseTo(base.summary.totalTaxSavings, 6); + expect(withTopLevel.years.every(y => y.extensionFraction === 1)).toBe(true); + expect(withTopLevel.years.every(y => y.deleverageGainRealized === 0)).toBe(true); + }); + + it('two identical legs each carrying the same plan == single-strategy deleverage', () => { + // Both legs = core-145-45 at $5M each with the same all-at-once plan; the + // blended book must match a single-strategy $10M deleverage of core-145-45. + const single = calculate( + createInputs({ deleveragePlan: plan({ startYear: 4, durationYears: 1 }) }), + DEFAULT_SETTINGS + ); + const p = plan({ startYear: 4, durationYears: 1 }); + const splitBoth = calculate( + splitInputs( + {}, + { + coreStrategyId: 'core-145-45', + overlayStrategyId: 'core-145-45', + coreDeleverage: p, + overlayDeleverage: p, + } + ), + DEFAULT_SETTINGS + ); + expect(splitBoth.years.length).toBe(single.years.length); + for (let i = 0; i < single.years.length; i++) { + expect(splitBoth.years[i].effectiveStLossRate).toBeCloseTo( + single.years[i].effectiveStLossRate, + 10 + ); + expect(splitBoth.years[i].extensionFraction).toBeCloseTo( + single.years[i].extensionFraction, + 10 + ); + expect(splitBoth.years[i].deleverageGainRealized).toBeCloseTo( + single.years[i].deleverageGainRealized, + 2 + ); + expect(splitBoth.years[i].taxSavings).toBeCloseTo(single.years[i].taxSavings, 2); + } + }); + + it('overlay-only plan delevers just that leg — extension % is collateral-weighted', () => { + // 50/50 split, overlay glides all-at-once at year 4, core stays static. + const result = calculate( + splitInputs({}, { overlayDeleverage: plan({ startYear: 4, durationYears: 1 }) }), + DEFAULT_SETTINGS + ); + // Year 3: nothing unwound yet — fully extended. + expect(result.years[2].extensionFraction).toBe(1); + // Year 4: overlay (50% of collateral) drops to w=0, core stays w=1 → + // collateral-weighted extension = 0.5. + expect(result.years[3].extensionFraction).toBeCloseTo(0.5, 6); + expect(result.years[3].deleverageGainRealized).toBeGreaterThan(0); + // The core leg carries no plan, so it never adds unwind gains. + const coreOnly = calculate( + splitInputs({}, { coreDeleverage: plan({ startYear: 4, durationYears: 1 }) }), + DEFAULT_SETTINGS + ); + expect(coreOnly.years[3].extensionFraction).toBeCloseTo(0.5, 6); + }); + + it('both legs delever independently on their own schedules', () => { + const result = calculate( + splitInputs( + {}, + { + coreDeleverage: plan({ startYear: 4, durationYears: 1 }), + overlayDeleverage: plan({ startYear: 6, durationYears: 1 }), + } + ), + DEFAULT_SETTINGS + ); + // Year 4: only the core leg (50%) unwinds → extension 0.5, gain realized. + expect(result.years[3].extensionFraction).toBeCloseTo(0.5, 6); + expect(result.years[3].deleverageGainRealized).toBeGreaterThan(0); + // Year 6: the overlay leg unwinds → book fully delevered (both at w=0). + expect(result.years[5].extensionFraction).toBeCloseTo(0, 6); + expect(result.years[5].deleverageGainRealized).toBeGreaterThan(0); + }); + + it('per-leg pool: concentrated overlay basis raises the overlay unwind gain', () => { + // Same overlay plan; with a low book cost basis the embedded gain is + // assigned to the overlay leg, so its unwind realizes MORE than the + // basis-equals-value case. + const noBasis = calculate( + splitInputs({}, { overlayDeleverage: plan({ startYear: 4, durationYears: 1 }) }), + DEFAULT_SETTINGS + ); + const lowBasis = calculate( + splitInputs( + { collateralCostBasis: 2000000 }, + { overlayDeleverage: plan({ startYear: 4, durationYears: 1 }) } + ), + DEFAULT_SETTINGS + ); + expect(lowBasis.years[3].deleverageGainRealized).toBeGreaterThan( + noBasis.years[3].deleverageGainRealized + ); + }); + + it('per-leg unwind attribution: core/overlay gains sum to the book gain', () => { + const result = calculate( + splitInputs( + {}, + { + coreDeleverage: plan({ startYear: 4, durationYears: 1 }), + overlayDeleverage: plan({ startYear: 6, durationYears: 1 }), + } + ), + DEFAULT_SETTINGS + ); + for (const y of result.years) { + // Fields are defined every year in split+deleverage mode. + expect(y.coreDeleverageGain).toBeDefined(); + expect(y.overlayDeleverageGain).toBeDefined(); + expect((y.coreDeleverageGain ?? 0) + (y.overlayDeleverageGain ?? 0)).toBeCloseTo( + y.deleverageGainRealized, + 4 + ); + } + // Year 4: only the core leg unwinds → core gain > 0, overlay gain 0. + expect(result.years[3].coreDeleverageGain).toBeGreaterThan(0); + expect(result.years[3].overlayDeleverageGain).toBeCloseTo(0, 6); + // Year 6: only the overlay leg unwinds → overlay gain > 0, core gain 0. + expect(result.years[5].overlayDeleverageGain).toBeGreaterThan(0); + expect(result.years[5].coreDeleverageGain).toBeCloseTo(0, 6); + }); + + it('per-leg attribution fields are undefined in single-strategy mode', () => { + const single = calculate( + createInputs({ deleveragePlan: plan({ startYear: 4, durationYears: 1 }) }), + DEFAULT_SETTINGS + ); + expect(single.years.every(y => y.coreDeleverageGain === undefined)).toBe(true); + expect(single.years.every(y => y.overlayDeleverageGain === undefined)).toBe(true); + }); + + it('unwind tax is endogenous: the savings identity still holds (QFAF mode)', () => { + const withPlan = calculate( + splitInputs( + { qfafEnabled: true, qfafSizingYears: 1 }, + { overlayDeleverage: plan({ startYear: 4, durationYears: 1 }) } + ), + DEFAULT_SETTINGS + ); + const y4 = withPlan.years[3]; + expect(y4.deleverageGainRealized).toBeGreaterThan(0); + expect(y4.taxSavings).toBeCloseTo( + y4.ordinaryLossBenefit + + y4.capitalLossBenefit + + y4.nolUsageBenefit - + y4.ltGainCost - + y4.remainingStGainCost, + 4 + ); + }); + + it('financing follows each leg glide (financingSaved accrues only with fees on)', () => { + const feeSettings = { + ...DEFAULT_SETTINGS, + financingFeesEnabled: true, + financingMode: 'simple' as const, + }; + const result = calculate( + splitInputs({}, { overlayDeleverage: plan({ startYear: 4, durationYears: 1 }) }), + feeSettings + ); + // Some financing is saved once the overlay delevers (year 4+). + expect(result.years[3].financingSaved).toBeGreaterThan(0); + // Gross (fees off) → no financing saved anywhere. + const gross = calculate( + splitInputs({}, { overlayDeleverage: plan({ startYear: 4, durationYears: 1 }) }), + DEFAULT_SETTINGS + ); + expect(gross.years.every(y => y.financingSaved === 0)).toBe(true); + }); +}); + describe('exit-tax interaction (no double taxation)', () => { it('embedded gain at horizon is reduced exactly by realized unwind gains', () => { const result = calculate(createInputs({ deleveragePlan: plan() }), DEFAULT_SETTINGS); @@ -456,6 +658,29 @@ describe('CSV round-trip', () => { expect(parsed.deleveragePlan).toEqual(inputs.deleveragePlan); }); + it('round-trips per-leg split deleverage plans (D-028)', () => { + const inputs = splitInputs( + {}, + { + coreDeleverage: plan({ startYear: 3, durationYears: 2, target: 'core-130-30' }), + overlayDeleverage: plan({ + startYear: 6, + durationYears: 1, + target: LONG_ONLY_TARGET, + lotSelectionHaircut: 0.8, + }), + } + ); + const { inputs: parsed, warnings } = parseInputsFromCsv( + exportInputsToCsv(inputs, DEFAULT_SETTINGS) + ); + expect(warnings).toEqual([]); + expect(parsed.splitAllocation?.coreDeleverage).toEqual(inputs.splitAllocation!.coreDeleverage); + expect(parsed.splitAllocation?.overlayDeleverage).toEqual( + inputs.splitAllocation!.overlayDeleverage + ); + }); + it('round-trips a minimal plan (no optional knobs) and omits absent plans', () => { const inputs = createInputs({ deleveragePlan: plan() }); const { inputs: parsed } = parseInputsFromCsv(exportInputsToCsv(inputs, DEFAULT_SETTINGS)); diff --git a/src/popupContent.ts b/src/popupContent.ts index 7f12a07..e2ade72 100644 --- a/src/popupContent.ts +++ b/src/popupContent.ts @@ -838,6 +838,33 @@ export const POPUP_CONTENT: Record = { 'This portion is netted with current-year strategy losses and capital-loss carryforwards before any remaining amount is taxed at long-term rates.', }, + 'col-deleverage-core-gain': { + title: 'Core Unwind Gain', + definition: + 'Portion of this year’s deleveraging gain realized by the CORE leg unwinding its own ' + + 'extension (split allocation only, D-028). Each leg unwinds against its own ' + + 'embedded-gain pool; the book’s pre-existing gain is assigned to the overlay ' + + '(appreciated-stock) leg first, so the core’s share reflects gains it actually built.', + formula: 'Core leg: Lot Haircut × Core Embedded Gain % × Core Extension Dollars Unwound', + impact: + 'Summed with the Overlay Unwind Gain to the book Unwind Gain, which nets with the ' + + 'strategy’s own flows and carryforwards before any residue is taxed.', + }, + + 'col-deleverage-overlay-gain': { + title: 'Overlay Unwind Gain', + definition: + 'Portion of this year’s deleveraging gain realized by the OVERLAY leg unwinding its own ' + + 'extension (split allocation only, D-028). The overlay is the appreciated-stock sleeve, ' + + 'so the book’s pre-existing embedded gain is assigned here first — its unwind cost is ' + + 'typically the larger share.', + formula: + 'Overlay leg: Lot Haircut × Overlay Embedded Gain % × Overlay Extension Dollars Unwound', + impact: + 'Summed with the Core Unwind Gain to the book Unwind Gain, which nets with the ' + + 'strategy’s own flows and carryforwards before any residue is taxed.', + }, + 'col-deleverage-tax': { title: 'Tax on Unwind', definition: diff --git a/src/types.ts b/src/types.ts index f7150e5..230e69e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,6 +18,14 @@ export interface SplitAllocation { coreAmount: number; overlayStrategyId: string; overlayAmount: number; + // Per-leg deleverage plans (D-028). In split mode each leg can glide its own + // extension to a lower-leverage target on its own schedule; the top-level + // `inputs.deleveragePlan` stays single-strategy-only and is ignored when + // split is enabled. Each leg resolves and unwinds against its OWN + // embedded-gain pool, so a concentrated overlay's unwind cost is attributed + // separately from the core. Undefined/absent = that leg stays fully extended. + coreDeleverage?: DeleveragePlan; + overlayDeleverage?: DeleveragePlan; } // Deleveraging plan (D-016/D-017): unwind the extension strategy to a @@ -253,6 +261,12 @@ export interface YearResult { deleverageTax: number; /** (source financing rate − blended rate) × start-of-year collateral; 0 when fees disabled */ financingSaved: number; + // Per-leg unwind attribution (D-028, split mode only). Undefined in + // single-strategy mode; when defined they sum to `deleverageGainRealized`. + /** Core-leg unwind gain realized this year (long + short cover). */ + coreDeleverageGain?: number; + /** Overlay-leg unwind gain realized this year (long + short cover). */ + overlayDeleverageGain?: number; } export interface CalculationResult { diff --git a/src/utils/csvScenario.ts b/src/utils/csvScenario.ts index ebdc047..b59357f 100644 --- a/src/utils/csvScenario.ts +++ b/src/utils/csvScenario.ts @@ -38,6 +38,25 @@ function csvRow(field: string, value: string | number | boolean): string { return `${csvEscape(field)},${csvEscape(v)}`; } +// Serialize a DeleveragePlan under an arbitrary key prefix (D-028 — the same +// shape backs the top-level plan and the per-leg split plans). Optional knobs +// are only emitted when explicitly set, matching the single-plan behavior. +function pushDeleveragePlan(lines: string[], prefix: string, plan: DeleveragePlan): void { + lines.push(csvRow(`${prefix}.enabled`, plan.enabled)); + lines.push(csvRow(`${prefix}.startYear`, plan.startYear)); + lines.push(csvRow(`${prefix}.durationYears`, plan.durationYears)); + lines.push(csvRow(`${prefix}.target`, plan.target)); + if (plan.unwindGainCharacter !== undefined) { + lines.push(csvRow(`${prefix}.unwindGainCharacter`, plan.unwindGainCharacter)); + } + if (plan.lotSelectionHaircut !== undefined) { + lines.push(csvRow(`${prefix}.lotSelectionHaircut`, plan.lotSelectionHaircut)); + } + if (plan.shortCoverGainPct !== undefined) { + lines.push(csvRow(`${prefix}.shortCoverGainPct`, plan.shortCoverGainPct)); + } +} + // Parse a single CSV line into [field, value]. Returns undefined for malformed // rows. Handles quoted values with embedded commas and escaped quotes. function parseCsvLine(line: string): [string, string] | undefined { @@ -132,23 +151,10 @@ export function exportInputsToCsv(inputs: CalculatorInputs, settings: AdvancedSe // Deleveraging plan (D-016/D-017); optional knobs only when overridden if (inputs.deleveragePlan) { - const plan = inputs.deleveragePlan; - lines.push(csvRow('deleveragePlan.enabled', plan.enabled)); - lines.push(csvRow('deleveragePlan.startYear', plan.startYear)); - lines.push(csvRow('deleveragePlan.durationYears', plan.durationYears)); - lines.push(csvRow('deleveragePlan.target', plan.target)); - if (plan.unwindGainCharacter !== undefined) { - lines.push(csvRow('deleveragePlan.unwindGainCharacter', plan.unwindGainCharacter)); - } - if (plan.lotSelectionHaircut !== undefined) { - lines.push(csvRow('deleveragePlan.lotSelectionHaircut', plan.lotSelectionHaircut)); - } - if (plan.shortCoverGainPct !== undefined) { - lines.push(csvRow('deleveragePlan.shortCoverGainPct', plan.shortCoverGainPct)); - } + pushDeleveragePlan(lines, 'deleveragePlan', inputs.deleveragePlan); } - // Split allocation + // Split allocation, incl. per-leg deleverage plans (D-028) if (inputs.splitAllocation) { lines.push(csvRow('splitAllocation.enabled', inputs.splitAllocation.enabled)); lines.push(csvRow('splitAllocation.coreStrategyId', inputs.splitAllocation.coreStrategyId)); @@ -157,6 +163,20 @@ export function exportInputsToCsv(inputs: CalculatorInputs, settings: AdvancedSe csvRow('splitAllocation.overlayStrategyId', inputs.splitAllocation.overlayStrategyId) ); lines.push(csvRow('splitAllocation.overlayAmount', inputs.splitAllocation.overlayAmount)); + if (inputs.splitAllocation.coreDeleverage) { + pushDeleveragePlan( + lines, + 'splitAllocation.coreDeleverage', + inputs.splitAllocation.coreDeleverage + ); + } + if (inputs.splitAllocation.overlayDeleverage) { + pushDeleveragePlan( + lines, + 'splitAllocation.overlayDeleverage', + inputs.splitAllocation.overlayDeleverage + ); + } } // Settings (user-tweakable subset) @@ -240,6 +260,84 @@ function parseNum(value: string, warnings: string[], field: string): number | un return n; } +/** + * Parse a DeleveragePlan stored under `prefix` from the field map (D-028 — + * shared by the top-level plan and the per-leg split plans). Consumes the keys + * it reads (so they don't surface as "unknown field" warnings) and returns + * undefined when no key for that prefix is present. + */ +function parseDeleveragePlan( + prefix: string, + map: Map, + warnings: string[] +): DeleveragePlan | undefined { + const dlv: Partial = {}; + let touched = false; + + const enabledRaw = map.get(`${prefix}.enabled`); + if (enabledRaw !== undefined) { + map.delete(`${prefix}.enabled`); + const b = parseBool(enabledRaw, warnings, `${prefix}.enabled`); + if (b !== undefined) { + dlv.enabled = b; + touched = true; + } + } + + const numKeys = [ + 'startYear', + 'durationYears', + 'lotSelectionHaircut', + 'shortCoverGainPct', + ] as const; + for (const sub of numKeys) { + const key = `${prefix}.${sub}`; + const raw = map.get(key); + if (raw === undefined) continue; + map.delete(key); + const n = parseNum(raw, warnings, key); + if (n === undefined) continue; + touched = true; + if (sub === 'startYear') dlv.startYear = n; + else if (sub === 'durationYears') dlv.durationYears = n; + else if (sub === 'lotSelectionHaircut') dlv.lotSelectionHaircut = n; + else dlv.shortCoverGainPct = n; + } + + const targetRaw = map.get(`${prefix}.target`); + if (targetRaw !== undefined) { + map.delete(`${prefix}.target`); + if (targetRaw) { + dlv.target = targetRaw; + touched = true; + } + } + + const charRaw = map.get(`${prefix}.unwindGainCharacter`); + if (charRaw !== undefined) { + map.delete(`${prefix}.unwindGainCharacter`); + if (charRaw === 'st' || charRaw === 'lt') { + dlv.unwindGainCharacter = charRaw; + touched = true; + } else if (charRaw) { + warnings.push( + `${prefix}.unwindGainCharacter: expected "st" or "lt", got "${charRaw}" — ignoring.` + ); + } + } + + if (!touched) return undefined; + return { + enabled: dlv.enabled ?? false, + startYear: dlv.startYear ?? 1, + durationYears: dlv.durationYears ?? 1, + target: dlv.target ?? 'long-only', + ...(dlv.unwindGainCharacter !== undefined && { unwindGainCharacter: dlv.unwindGainCharacter }), + ...(dlv.lotSelectionHaircut !== undefined && { lotSelectionHaircut: dlv.lotSelectionHaircut }), + ...(dlv.shortCoverGainPct !== undefined && { shortCoverGainPct: dlv.shortCoverGainPct }), + }; +} + const FILING_STATUS_VALUES = new Set(FILING_STATUSES.map(s => s.value)); /** @@ -369,76 +467,9 @@ export function parseInputsFromCsv(csvText: string): ParsedScenario { inputs.nycResident = b; }); - // Deleveraging plan: assemble whatever keys are present, then attach if any. - const dlv: Partial = {}; - let dlvTouched = false; - { - const raw = map.get('deleveragePlan.enabled'); - if (raw !== undefined) { - map.delete('deleveragePlan.enabled'); - const b = parseBool(raw, warnings, 'deleveragePlan.enabled'); - if (b !== undefined) { - dlv.enabled = b; - dlvTouched = true; - } - } - } - for (const key of [ - 'deleveragePlan.startYear', - 'deleveragePlan.durationYears', - 'deleveragePlan.lotSelectionHaircut', - 'deleveragePlan.shortCoverGainPct', - ] as const) { - const raw = map.get(key); - if (raw === undefined) continue; - map.delete(key); - const n = parseNum(raw, warnings, key); - if (n === undefined) continue; - dlvTouched = true; - if (key === 'deleveragePlan.startYear') dlv.startYear = n; - else if (key === 'deleveragePlan.durationYears') dlv.durationYears = n; - else if (key === 'deleveragePlan.lotSelectionHaircut') dlv.lotSelectionHaircut = n; - else dlv.shortCoverGainPct = n; - } - { - const raw = map.get('deleveragePlan.target'); - if (raw !== undefined) { - map.delete('deleveragePlan.target'); - if (raw) { - dlv.target = raw; - dlvTouched = true; - } - } - } - { - const raw = map.get('deleveragePlan.unwindGainCharacter'); - if (raw !== undefined) { - map.delete('deleveragePlan.unwindGainCharacter'); - if (raw === 'st' || raw === 'lt') { - dlv.unwindGainCharacter = raw; - dlvTouched = true; - } else if (raw) { - warnings.push( - `deleveragePlan.unwindGainCharacter: expected "st" or "lt", got "${raw}" — ignoring.` - ); - } - } - } - if (dlvTouched) { - inputs.deleveragePlan = { - enabled: dlv.enabled ?? false, - startYear: dlv.startYear ?? 1, - durationYears: dlv.durationYears ?? 1, - target: dlv.target ?? 'long-only', - ...(dlv.unwindGainCharacter !== undefined && { - unwindGainCharacter: dlv.unwindGainCharacter, - }), - ...(dlv.lotSelectionHaircut !== undefined && { - lotSelectionHaircut: dlv.lotSelectionHaircut, - }), - ...(dlv.shortCoverGainPct !== undefined && { shortCoverGainPct: dlv.shortCoverGainPct }), - }; - } + // Deleveraging plan (top-level, single-strategy mode). + const topLevelPlan = parseDeleveragePlan('deleveragePlan', map, warnings); + if (topLevelPlan) inputs.deleveragePlan = topLevelPlan; // Split allocation: assemble whatever keys are present, then attach if any. const split: Partial = {}; @@ -487,6 +518,10 @@ export function parseInputsFromCsv(csvText: string): ParsedScenario { splitNum('splitAllocation.overlayAmount', n => { split.overlayAmount = n; }); + // Per-leg deleverage plans (D-028). Their presence alone implies a split. + const coreDeleverage = parseDeleveragePlan('splitAllocation.coreDeleverage', map, warnings); + const overlayDeleverage = parseDeleveragePlan('splitAllocation.overlayDeleverage', map, warnings); + if (coreDeleverage || overlayDeleverage) splitTouched = true; if (splitTouched) { inputs.splitAllocation = { enabled: split.enabled ?? false, @@ -494,6 +529,8 @@ export function parseInputsFromCsv(csvText: string): ParsedScenario { coreAmount: split.coreAmount ?? 0, overlayStrategyId: split.overlayStrategyId ?? 'overlay-45-45', overlayAmount: split.overlayAmount ?? 0, + ...(coreDeleverage && { coreDeleverage }), + ...(overlayDeleverage && { overlayDeleverage }), }; } diff --git a/src/utils/excelExport.ts b/src/utils/excelExport.ts index c1376f5..c8232aa 100644 --- a/src/utils/excelExport.ts +++ b/src/utils/excelExport.ts @@ -111,6 +111,8 @@ export async function buildWorkbook(data: ExportData): Promise { // Sheet 2: Year-by-Year. Deleverage columns (D-016) appear only when a // plan actually unwound something — keeps the default export uncluttered. const hasDeleverage = data.results.years.some(y => y.extensionFraction < 1); + // Per-leg unwind attribution (D-028) — split mode only. + const hasPerLegDeleverage = data.results.years.some(y => y.overlayDeleverageGain !== undefined); const yearHeaders: (string | number)[] = [ 'Year', 'Collateral Value', @@ -126,6 +128,7 @@ export async function buildWorkbook(data: ExportData): Promise { 'Tax Savings', 'Income Offset', ...(hasDeleverage ? ['Extension %', 'Unwind Gain', 'Tax on Unwind', 'Financing Saved'] : []), + ...(hasPerLegDeleverage ? ['Core Unwind Gain', 'Overlay Unwind Gain'] : []), ]; const yearRows: (string | number)[][] = [yearHeaders]; for (const y of data.results.years) { @@ -146,6 +149,7 @@ export async function buildWorkbook(data: ExportData): Promise { ...(hasDeleverage ? [y.extensionFraction, y.deleverageGainRealized, y.deleverageTax, y.financingSaved] : []), + ...(hasPerLegDeleverage ? [y.coreDeleverageGain ?? 0, y.overlayDeleverageGain ?? 0] : []), ]); } const yearSheet = XLSX.utils.aoa_to_sheet(yearRows); diff --git a/src/workspace/WorkspaceTab.test.tsx b/src/workspace/WorkspaceTab.test.tsx index dc03f62..6372323 100644 --- a/src/workspace/WorkspaceTab.test.tsx +++ b/src/workspace/WorkspaceTab.test.tsx @@ -7,8 +7,10 @@ * existing engine support (one-engine rule) — proven by comparing the * Workspace-rendered headline to a direct calculate() run with the same * inputs.splitAllocation. - * - D-016 conflict: deleverage plan is ignored when split is on; the rail - * surfaces the existing warning wording. + * - D-028 split + deleverage: in split mode the rail offers PER-LEG deleverage + * plans (core + overlay) instead of the single-strategy plan, and each + * per-leg plan actually delevers its leg in the engine. The top-level plan + * stays single-strategy-only (still ignored when split is on). */ import { describe, it, expect, afterEach } from 'vitest'; @@ -187,18 +189,23 @@ describe('Workspace split allocation (D-026)', () => { expect(Math.round(expected.sizing.totalExposure)).toBe(20000000); }); - it('deleverage + split shows the D-016 conflict warning in the rail', () => { + it('deleverage + split: the rail offers per-leg plans, not the single plan (D-028)', () => { ackQp(); const r = render(); - fireEvent.click(r.getByLabelText('Plan deleveraging')); - expect(r.queryByTestId('ws-split-dlv-warn')).toBeNull(); // no conflict yet + // Single mode: the single-strategy "Plan deleveraging" toggle is shown. + expect(r.queryByLabelText('Plan deleveraging')).not.toBeNull(); + expect(r.queryByTestId('ws-leg-dlv-overlay')).toBeNull(); + // Turn split on: the single plan toggle is replaced by per-leg editors. fireEvent.click(r.getByLabelText('Split allocation (core + overlay)')); - expect(r.getByTestId('ws-split-dlv-warn').textContent).toContain('plan ignored'); + expect(r.queryByLabelText('Plan deleveraging')).toBeNull(); + expect(r.getByTestId('ws-leg-dlv-core')).toBeTruthy(); + expect(r.getByTestId('ws-leg-dlv-overlay')).toBeTruthy(); + }); - // And the engine really ignores the plan: results match a split run - // without any deleverage plan (D-016: split wins). - const withPlanIgnored = calculate( + it('deleverage + split: a per-leg plan actually delevers that leg (D-028)', () => { + // The top-level plan stays single-strategy-only (still ignored in split)… + const topLevelIgnored = calculate( { ...DEFAULTS, splitAllocation: SPLIT_ON, @@ -207,7 +214,23 @@ describe('Workspace split allocation (D-026)', () => { DEFAULT_SETTINGS ); const noPlan = calculate({ ...DEFAULTS, splitAllocation: SPLIT_ON }, DEFAULT_SETTINGS); - expect(withPlanIgnored.summary.totalTaxSavings).toBe(noPlan.summary.totalTaxSavings); + expect(topLevelIgnored.summary.totalTaxSavings).toBe(noPlan.summary.totalTaxSavings); + expect(topLevelIgnored.years.every(y => y.extensionFraction === 1)).toBe(true); + + // …but a PER-LEG plan on the overlay leg does delever it (D-028). + const perLeg = calculate( + { + ...DEFAULTS, + splitAllocation: { + ...SPLIT_ON, + overlayDeleverage: { enabled: true, startYear: 3, durationYears: 1, target: 'long-only' }, + }, + }, + DEFAULT_SETTINGS + ); + expect(perLeg.years.some(y => y.extensionFraction < 1)).toBe(true); + expect(perLeg.years.some(y => y.deleverageGainRealized > 0)).toBe(true); + expect(perLeg.summary.totalTaxSavings).not.toBe(noPlan.summary.totalTaxSavings); }); it('split scenario round-trips through the Workspace CSV export path', () => { diff --git a/src/workspace/WorkspaceTab.tsx b/src/workspace/WorkspaceTab.tsx index d1d1fb6..047d22c 100644 --- a/src/workspace/WorkspaceTab.tsx +++ b/src/workspace/WorkspaceTab.tsx @@ -946,6 +946,103 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { const dlvTotalGain = results.years.reduce((s, y) => s + y.deleverageGainRealized, 0); const dlvTotalTax = results.years.reduce((s, y) => s + y.deleverageTax, 0); const dlvTotalFinSaved = results.years.reduce((s, y) => s + y.financingSaved, 0); + + // Per-leg deleverage (D-028): in split mode each leg carries its own plan on + // inputs.splitAllocation (legs[0] = core, legs[1] = overlay). The top-level + // plan above stays single-strategy-only. + const coreDlv = inputs.splitAllocation?.coreDeleverage; + const overlayDlv = inputs.splitAllocation?.overlayDeleverage; + const setLegDlv = (leg: 'core' | 'overlay', patch: Partial) => { + const existing = leg === 'core' ? coreDlv : overlayDlv; + const key = leg === 'core' ? 'coreDeleverage' : 'overlayDeleverage'; + updateSplit({ + [key]: { + enabled: false, + startYear: Math.min(3, projYears), + durationYears: 1, + target: LONG_ONLY_TARGET, + ...existing, + ...patch, + }, + }); + }; + // Eligible targets for a leg: long-only DI plus lower-leverage strategies of + // the same type (deleveraging, not restyling — mirrors the single path). + const legDlvTargets = (strategyId: string) => { + const s = getStrategy(strategyId); + return s + ? STRATEGIES.filter(t => t.type === s.type && getShortRatio(t) < getShortRatio(s)) + : []; + }; + const splitDlvActive = coreDlv?.enabled === true || overlayDlv?.enabled === true; + // One leg's deleverage editor — shared by the core and overlay legs in split + // mode (D-028). Mirrors the single-strategy editor's controls. + const renderLegDlv = ( + leg: 'core' | 'overlay', + legStrategyId: string, + legPlan: DeleveragePlan | undefined + ) => { + const targets = legDlvTargets(legStrategyId); + const legStratName = getStrategy(legStrategyId)?.name ?? legStrategyId; + const onPatch = (patch: Partial) => setLegDlv(leg, patch); + return ( +
+ + {legPlan?.enabled && ( + <> + + + + + )} +
+ ); + }; // The engine extends past the standard horizon when NOL remains unused // (mirrors core.ts: QFAF duration + partial-year start + 2 wind-down years). const baseHorizonYears = Math.max( @@ -1138,14 +1235,18 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { }); } if (dlvPlan?.enabled && splitOn) { + // D-028: split mode runs PER-LEG plans; the single-strategy top-level plan + // is still ignored. Point the user at the per-leg controls rather than + // silently dropping it. flags.push({ key: 'split-dlv', sev: 'warn', tag: 'Split', body: ( <> - Deleveraging ignored: deleveraging isn't modeled with split - allocation yet — plan ignored. + Top-level deleverage plan ignored: with split allocation on, deleveraging + is set per leg in the Deleveraging group — the single top-level plan + doesn't apply. ), }); @@ -1286,6 +1387,34 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { ), }); } + if (dlvActive && splitOn && splitDlvActive) { + // D-028: per-leg deleverage narrative. Each enabled leg unwinds its own + // extension; the book-level totals already aggregate across legs. + const legNames = [ + coreDlv?.enabled ? 'core' : null, + overlayDlv?.enabled ? 'overlay' : null, + ].filter(Boolean); + flags.push({ + key: 'deleverage-split', + sev: 'info', + tag: 'Delever', + body: ( + <> + Deleveraging (split): the {legNames.join(' and ')}{' '} + {legNames.length > 1 ? 'legs unwind' : 'leg unwinds'} on{' '} + {legNames.length > 1 ? 'their own schedules' : 'its own schedule'}, realizing{' '} + {formatCurrency(dlvTotalGain)} of unwind gains across the book, of which only{' '} + {formatCurrency(dlvTotalTax)} is taxed — the rest is absorbed by harvested losses and the + carryforward reserve. Each leg unwinds against its own embedded-gain pool.{' '} + {settings.financingFeesEnabled ? ( + <>Financing fees saved vs staying levered: {formatCurrency(dlvTotalFinSaved)}. + ) : ( + <>Enable financing costs & fees to see the financing saved. + )} + + ), + }); + } if (nolExtensionYears > 0) { flags.push({ key: 'nol-extension', @@ -1860,11 +1989,22 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { + {coreDlv?.enabled && <>core} + {coreDlv?.enabled && overlayDlv?.enabled && <> · } + {overlayDlv?.enabled && <>overlay} leg + {coreDlv?.enabled && overlayDlv?.enabled ? 's' : ''} + + ) : ( + 'Off' + ) + ) : dlvPlan?.enabled ? ( <> Yr {dlvPlan.startYear} ·{' '} {dlvPlan.durationYears === 1 ? 'all at once' : `${dlvPlan.durationYears} yrs`} ·{' '} @@ -1875,73 +2015,90 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { ) } > -

What the deleverage model assumes

- {/* D-016/D-026: split wins when both are enabled — same wording as - the results-pane flag (core.ts ignores the plan). */} - {dlvPlan?.enabled && splitOn && ( -

- ⚠️ Deleveraging isn't modeled with split allocation yet — plan ignored. -

- )} - {dlvPlan?.enabled && ( + {splitOn ? ( + // D-028: per-leg deleverage — each split leg glides independently. <> - -
diff --git a/src/workspace/workspace.css b/src/workspace/workspace.css index f649713..cc44246 100644 --- a/src/workspace/workspace.css +++ b/src/workspace/workspace.css @@ -657,6 +657,16 @@ line-height: 1.45; } +/* Per-leg deleverage editors (D-028): one boxed block per split leg. */ +.ws-leg-dlv { + padding: 9px 10px; + border: 1px solid var(--wx-line); + border-radius: var(--wx-r-sm); + display: flex; + flex-direction: column; + gap: 8px; +} + /* ════════════════════ COMMAND PALETTE (⌘K) ════════════════════ */ .wx-palette-overlay { position: fixed;