diff --git a/frontend/src/components/ScenarioDetail.tsx b/frontend/src/components/ScenarioDetail.tsx index 75bbf038..5c3d9be2 100644 --- a/frontend/src/components/ScenarioDetail.tsx +++ b/frontend/src/components/ScenarioDetail.tsx @@ -685,11 +685,41 @@ export function YearDetailPanel({
Withdrawals by account
-
- Taxable: {formatFull(year.withdrawals.taxable, currency)} - {' \u00B7 '}Tax-deferred: {formatFull(year.withdrawals.tax_deferred, currency)} - {' \u00B7 '}Tax-free: {formatFull(year.withdrawals.tax_free, currency)} -
+ {(() => { + // The legacy ``taxable`` total already folds yield spent directly + // (without selling shares) into the taxable-account withdrawal. + // Expose the additive split so ``investment_yield`` and ``taxable`` + // visibly reconcile against expenses. Default missing fields to 0 + // for backward compatibility with older stored snapshots. + const taxablePrincipal = year.withdrawals.taxable_principal ?? 0; + const taxableYieldConsumed = + year.withdrawals.taxable_yield_consumed ?? 0; + const showReconciliation = taxableYieldConsumed > 0; + return ( + <> +
+ Taxable: {formatFull( + showReconciliation ? taxablePrincipal : year.withdrawals.taxable, + currency, + )} + {' \u00B7 '}Tax-deferred: {formatFull(year.withdrawals.tax_deferred, currency)} + {' \u00B7 '}Tax-free: {formatFull(year.withdrawals.tax_free, currency)} +
+ {showReconciliation && ( +
+ {formatFull(taxablePrincipal, currency)} principal sold + {' + '} + {formatFull(taxableYieldConsumed, currency)} yield consumed + {' = '} + {formatFull(year.withdrawals.taxable, currency)} +
+ )} + + ); + })()}
{year.capital_gains_realized > 0 && ( diff --git a/simulation_engine/detail.py b/simulation_engine/detail.py index 8304c4cc..b7b98687 100644 --- a/simulation_engine/detail.py +++ b/simulation_engine/detail.py @@ -146,6 +146,14 @@ class WithdrawalBreakdown: tax_deferred: float tax_free: float total: float + # Additive split of the taxable-account figure: ``taxable_principal`` is the + # value of taxable-account shares sold to fund spending; ``taxable_yield_consumed`` + # is the portion of the year's investment yield spent directly without + # selling shares. ``taxable_principal + taxable_yield_consumed == taxable``. + # Optional with 0.0 defaults so legacy fixtures / stored artifacts that + # predate these fields still validate. + taxable_principal: float = 0.0 + taxable_yield_consumed: float = 0.0 @dataclass @@ -1793,6 +1801,8 @@ def replay_scenario_with_accounting( tax_deferred=actual_withdrawals["tax_deferred"] / cumulative_inflation, tax_free=actual_withdrawals["tax_free"] / cumulative_inflation, total=(total_principal_withdrawn + yield_spent_nominal) / cumulative_inflation, + taxable_principal=actual_withdrawals["taxable"] / cumulative_inflation, + taxable_yield_consumed=yield_spent_nominal / cumulative_inflation, ) englobamento_detail = getattr(annual_tax_result, "portugal_englobamento", None) diff --git a/tests/test_scenario_detail.py b/tests/test_scenario_detail.py index bfe54274..2828e850 100644 --- a/tests/test_scenario_detail.py +++ b/tests/test_scenario_detail.py @@ -564,6 +564,37 @@ def test_year_detail_fields_are_internally_consistent(): assert w.total == pytest.approx(w.taxable + w.tax_deferred + w.tax_free, abs=0.01) +def test_taxable_withdrawal_splits_into_principal_and_yield_consumed(): + """The legacy ``taxable`` field already folds yield spent directly into the + displayed taxable-account withdrawal. ``taxable_principal + + taxable_yield_consumed`` must equal ``taxable`` row-by-row, and at least one + retirement year must show a non-zero yield-consumed share so the + Scenario Detail panel can reconcile investment yield against expenses.""" + sc = _make_config( + horizon=15, + n_scenarios=1, + approximate_yield_rate=0.04, # Force non-trivial yield so spending hits it. + baseline_annual_expenses=60_000.0, + ) + _, _, detail = _run_and_replay(sc) + + saw_nonzero_yield_consumed = False + for year_detail in detail.years: + w = year_detail.withdrawals + assert w.taxable == pytest.approx( + w.taxable_principal + w.taxable_yield_consumed, abs=0.01 + ) + assert w.taxable_principal >= -0.01 + assert w.taxable_yield_consumed >= -0.01 + if w.taxable_yield_consumed > 0.01: + saw_nonzero_yield_consumed = True + + assert saw_nonzero_yield_consumed, ( + "Expected at least one year to consume taxable-account yield directly; " + "without yield-consumed > 0 the new field is untested." + ) + + def test_replay_surfaces_dual_tax_breakdown_for_us_citizen_abroad(): sc = _make_config( country="Portugal",