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({
+ >
+ )}
>
)}
@@ -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.
-