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);