diff --git a/src/ResultsTable.tsx b/src/ResultsTable.tsx index a69a807..6a9c84e 100644 --- a/src/ResultsTable.tsx +++ b/src/ResultsTable.tsx @@ -776,14 +776,22 @@ export function ResultsTable({ losses (carry forward). */} {(() => { const gap = year.stGainsGenerated - year.stLossesHarvested; + const excess = !isWindDown && gap > 1; return ( - 1 ? 'negative' : 'positive'}`}> + 1 ? 'negative' : 'positive'}${excess ? ' st-offset-excess' : ''}`} + title={ + excess + ? 'ST gains exceed harvested losses — the excess is taxed at ST rates' + : undefined + } + > {isWindDown ? '—' : Math.abs(gap) < 1 ? '$0' : gap > 0 - ? formatCurrency(gap) + ? `▲ ${formatCurrency(gap)}` : `(${formatCurrency(Math.abs(gap))})`} ); @@ -1257,11 +1265,13 @@ function TransposedTable({ return Math.abs(gap) < 1 ? '$0' : gap > 0 - ? money(gap) + ? `▲ ${money(gap)}` : `(${money(Math.abs(gap))})`; }, className: (y: YearResult) => - y.stGainsGenerated - y.stLossesHarvested > 1 ? 'negative' : 'positive', + active(y) && y.stGainsGenerated - y.stLossesHarvested > 1 + ? 'negative st-offset-excess' + : 'positive', }, ] : []), diff --git a/src/index.css b/src/index.css index a40d5eb..f376410 100644 --- a/src/index.css +++ b/src/index.css @@ -4771,6 +4771,14 @@ tfoot td { white-space: nowrap; } +/* Highlight per-year cells where the Fund's ST gains exceed harvested losses — + the unmatched excess is taxed at ST rates. Red tint + bold so it stands out + in a dense table. */ +.year-breakdown-table td.st-offset-excess { + background: rgba(196, 60, 45, 0.13); + font-weight: 650; +} + /* Expandable column headers */ .col-expandable { cursor: pointer; diff --git a/src/workspace/WorkspaceTab.tsx b/src/workspace/WorkspaceTab.tsx index dce2b87..3ccfa85 100644 --- a/src/workspace/WorkspaceTab.tsx +++ b/src/workspace/WorkspaceTab.tsx @@ -73,6 +73,7 @@ const I = { calendar: 'M5 4h14v16H5zM5 9h14M9 2v4M15 2v4', bolt: 'M13 2L4 14h6l-1 8 9-12h-6z', flag: 'M5 21V4M5 4h11l-2 4 2 4H5', + warn: 'M12 3L2 20h20L12 3zM12 10v4M12 17v.5', spark: 'M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5L18 18M18 6l-2.5 2.5M8.5 15.5L6 18', download: 'M12 3v12m-5-5l5 5 5-5M5 21h14', doc: 'M6 2h8l4 4v16H6zM14 2v4h4', @@ -1311,8 +1312,51 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { }, ]; + // Excess ST gains: years where the Fund generates MORE short-term gains than + // the collateral harvests in losses. The unmatched portion is offset by any + // ST carryforwards first, then taxed at short-term rates — the opposite of + // the strategy's goal. Common with a manual Fund amount set too high, or with + // Fixed sizing as the collateral's loss rate decays while the Fund is held + // constant. $1 floor ignores rounding noise (auto-sizing matches to cents). + const stExcessYears = results.years + .filter(y => y.strategyActive) + .map(y => ({ year: y.year, gap: y.stGainsGenerated - y.stLossesHarvested })) + .filter(y => y.gap > 1); + const stExcessTotal = stExcessYears.reduce((s, y) => s + y.gap, 0); + const stExcessTaxCost = results.years.reduce((s, y) => s + (y.remainingStGainCost ?? 0), 0); + const hasStExcess = stExcessYears.length > 0; + const stExcessPeakYear = stExcessYears.reduce( + (peak, y) => (y.gap > peak.gap ? y : peak), + stExcessYears[0] ?? { year: 0, gap: 0 } + ); + // ─── D-027: consolidated flags (severity-ordered: pos → warn → info) ─── const flags: WsFlag[] = []; + if (hasStExcess) { + flags.push({ + key: 'st-gain-excess', + sev: 'warn', + tag: brandText('QFAF'), + body: ( + <> + Excess ST gains: in {stExcessYears.length} year + {stExcessYears.length === 1 ? '' : 's'} the {brandText('Fund')} generates more short-term + gains than the collateral harvests in losses — up to{' '} + {formatCurrency(stExcessPeakYear.gap)} in year {stExcessPeakYear.year} ( + {formatCurrency(stExcessTotal)} total).{' '} + {stExcessTaxCost > 1 ? ( + <> + The unmatched gains add roughly {formatCurrency(stExcessTaxCost)} of short-term + tax over the projection. + + ) : ( + <>Existing ST carryforwards currently absorb them, but they erode that reserve. + )}{' '} + Reduce the {brandText('Fund')} size or use Dynamic sizing to tighten the offset. + + ), + }); + } if (exit.incrementalDeferredTax < 0) { flags.push({ key: 'exit-covered', @@ -2199,12 +2243,12 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { ? ` (avg, yrs 1–${results.sizing.sizingYears})` : ''; return ( -

+

{matched ? ( <>ST gains fully offset harvested losses{windowNote}. ) : netSt < 0 ? ( <> - {brandText('QFAF ST gains exceed collateral ST losses by')}{' '} + ⚠ {brandText('QFAF ST gains exceed collateral ST losses by')}{' '} {formatCurrency(Math.abs(netSt))} {windowNote} — the excess is taxed at ST rates. @@ -2629,6 +2673,32 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { {/* D-027: consolidated flags tray — all conditional notes in one place */} + {/* Always-visible alert (not tucked in the tray) when the Fund + over-generates ST gains vs harvested losses. */} + {hasStExcess && ( +

+ + + + {brandText('QFAF')} ST gains exceed harvested losses by{' '} + {formatCurrency(stExcessPeakYear.gap)} in year {stExcessPeakYear.year} + + + {stExcessYears.length === 1 + ? 'One year runs' + : `${stExcessYears.length} years run`}{' '} + a net ST gain ({formatCurrency(stExcessTotal)} total unmatched).{' '} + {stExcessTaxCost > 1 + ? `About ${formatCurrency(stExcessTaxCost)} of short-term tax over the projection.` + : 'Currently sheltered by ST carryforwards, which it draws down.'}{' '} + Reduce the {brandText('Fund')} size or use Dynamic sizing to close the gap. + + +
+ )} + {showEventsEditor && (
diff --git a/src/workspace/workspace.css b/src/workspace/workspace.css index 2e85ca3..f52673d 100644 --- a/src/workspace/workspace.css +++ b/src/workspace/workspace.css @@ -667,6 +667,52 @@ border: 1px solid var(--wx-warn); color: var(--wx-warn); } +/* High-visibility alert when the Fund over-generates ST gains vs harvested + losses — a genuinely undesirable state, so it uses the negative (red) + tokens and sits inline above the results, not tucked in the flags tray. */ +.wx-excess-banner { + display: flex; + gap: 12px; + align-items: flex-start; + margin: 12px 0; + padding: 12px 14px; + border-radius: var(--wx-r-sm, 8px); + background: var(--wx-neg-soft); + border: 1px solid var(--wx-neg); + border-left: 4px solid var(--wx-neg); + box-shadow: var(--wx-shadow); +} +.wx-excess-banner__icon { + color: var(--wx-neg); + flex-shrink: 0; + line-height: 0; + margin-top: 1px; +} +.wx-excess-banner__text { + display: flex; + flex-direction: column; + gap: 3px; + font-size: 12.5px; + line-height: 1.5; + color: var(--wx-ink-1); +} +.wx-excess-banner__text strong { + color: var(--wx-neg); + font-weight: 650; + font-size: 13px; +} +/* Red variant of the rail note for the excess-ST-gains case (undesirable). */ +.ws-rail-danger { + margin: 0; + padding: 7px 9px; + border-radius: var(--wx-r-sm); + font-size: 11.5px; + line-height: 1.45; + font-weight: 550; + background: var(--wx-neg-soft); + border: 1px solid var(--wx-neg); + color: var(--wx-neg); +} .ws-rail-note { font-size: 11px; color: var(--wx-ink-3);