Spaces:
Running
Running
| # -*- coding: utf-8 -*- | |
| """ | |
| Plot generators for the Tax Torpedo Analyzer. | |
| All functions save figures to PNG files and return the file path. | |
| No plt.show() calls -- designed for headless use. | |
| Uses the analyst's "reference taxable income" x-axis convention: | |
| x_plot = OI - Std. Ded. + 0.85 * SSB | |
| Elderly-friendly styling: large fonts, high contrast, clear annotations. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import tempfile | |
| from typing import Dict, List, Optional, Tuple | |
| import numpy as np | |
| import matplotlib | |
| matplotlib.use("Agg") # headless backend | |
| import matplotlib.pyplot as plt | |
| import matplotlib.ticker as mticker | |
| from tax_engine import ( | |
| CONFIGS, TaxConfig, | |
| ssb_tax, bracket_tax, compute_baseline_tax, tax_with_ssb, tax_with_ssb_detail, | |
| bracket_marginal_rate, total_marginal_rate, find_torpedo_bounds, | |
| classify_zone, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Global plot styling (elderly-friendly) | |
| # --------------------------------------------------------------------------- | |
| PLOT_STYLE = { | |
| "font.size": 14, | |
| "axes.titlesize": 18, | |
| "axes.labelsize": 16, | |
| "xtick.labelsize": 13, | |
| "ytick.labelsize": 13, | |
| "legend.fontsize": 12, | |
| "figure.dpi": 200, | |
| "figure.facecolor": "white", | |
| "axes.facecolor": "white", | |
| "axes.grid": True, | |
| "grid.alpha": 0.3, | |
| } | |
| ZONE_COLORS = { | |
| "No-Tax Zone": ("#c8e6c9", "green"), # light green bg, green text | |
| "High-Tax Zone": ("#ffcdd2", "#c62828"), # light red bg, red text | |
| "Same-Old Zone": ("#bbdefb", "#1565c0"), # light blue bg, blue text | |
| } | |
| # Darker zone label colors for text annotations above plot | |
| _ZONE_LABEL_COLORS = { | |
| "no_tax": "#1b5e20", # dark green | |
| "high_tax": "#b71c1c", # dark red | |
| "same_old": "#4a148c", # dark purple | |
| } | |
| X_AXIS_LABEL = "Reference Income: Other Income + 85% of SSB ($)" | |
| # Colors for multiple scenario positions | |
| _SCENARIO_COLORS = ["#1565c0", "#ff6f00", "#2e7d32", "#6a1b9a"] | |
| _SCENARIO_LABELS = ["Scenario A", "Scenario B", "Scenario C", "Scenario D"] | |
| def _save_fig(fig, prefix: str = "plot") -> str: | |
| """Save figure to a temp PNG and return the path.""" | |
| fd, path = tempfile.mkstemp(suffix=".png", prefix=f"tax_{prefix}_") | |
| os.close(fd) | |
| fig.savefig(path, dpi=300, bbox_inches="tight", facecolor="white") | |
| plt.close(fig) | |
| return path | |
| def _dollar_fmt(x, _=None): | |
| """Format axis ticks as $XX,XXX.""" | |
| return f"${x:,.0f}" | |
| def _pct_fmt(x, _=None): | |
| """Format axis ticks as XX%.""" | |
| return f"{x:.0f}%" | |
| def _add_zone_shading(ax, x_plot, ts_plot, te_plot): | |
| """Add zone shading to an axis WITHOUT legend labels.""" | |
| if ts_plot is not None: | |
| ax.axvspan(x_plot[0], ts_plot, color="green", alpha=0.08) | |
| if ts_plot is not None and te_plot is not None: | |
| ax.axvspan(ts_plot, te_plot, color="red", alpha=0.08) | |
| if te_plot is not None: | |
| ax.axvspan(te_plot, x_plot[-1], color="purple", alpha=0.06) | |
| def _add_zone_text_labels(ax, x_plot, ts_plot, te_plot): | |
| """Add zone text labels above the plot in darker zone colors.""" | |
| ylim = ax.get_ylim() | |
| label_y = ylim[1] # at the top of the visible area | |
| if ts_plot is not None: | |
| mid = (max(x_plot[0], 0) + ts_plot) / 2 | |
| ax.text(mid, label_y, "No-Tax Zone", ha="center", va="bottom", | |
| fontsize=12, fontweight="bold", color=_ZONE_LABEL_COLORS["no_tax"], | |
| clip_on=False) | |
| if ts_plot is not None and te_plot is not None: | |
| mid = (ts_plot + te_plot) / 2 | |
| ax.text(mid, label_y, "High-Tax Zone", ha="center", va="bottom", | |
| fontsize=12, fontweight="bold", color=_ZONE_LABEL_COLORS["high_tax"], | |
| clip_on=False) | |
| if te_plot is not None: | |
| mid = (te_plot + x_plot[-1]) / 2 | |
| ax.text(mid, label_y, "Same-Old Zone", ha="center", va="bottom", | |
| fontsize=12, fontweight="bold", color=_ZONE_LABEL_COLORS["same_old"], | |
| clip_on=False) | |
| # Expand y-axis slightly to make room for text labels | |
| ax.set_ylim(ylim[0], ylim[1] * 1.10) | |
| def _add_legend_below(fig, axes, extra_handles=None, extra_labels=None, ncol=None): | |
| """Collect legend handles from all axes and place a single row below the charts. | |
| *extra_handles* / *extra_labels* are appended to the collected items. | |
| """ | |
| handles, labels = [], [] | |
| seen = set() | |
| for ax in (axes if hasattr(axes, '__iter__') else [axes]): | |
| for h, l in zip(*ax.get_legend_handles_labels()): | |
| if l not in seen: | |
| handles.append(h) | |
| labels.append(l) | |
| seen.add(l) | |
| # Remove any per-axis legend | |
| legend = ax.get_legend() | |
| if legend: | |
| legend.remove() | |
| if extra_handles and extra_labels: | |
| for h, l in zip(extra_handles, extra_labels): | |
| if l not in seen: | |
| handles.append(h) | |
| labels.append(l) | |
| seen.add(l) | |
| if not handles: | |
| return | |
| if ncol is None: | |
| ncol = min(5, len(handles)) | |
| fig.legend( | |
| handles, labels, | |
| loc="lower center", | |
| ncol=min(ncol, len(handles)), | |
| fontsize=11, | |
| frameon=True, | |
| fancybox=True, | |
| shadow=False, | |
| borderpad=0.6, | |
| columnspacing=1.5, | |
| ) | |
| # Make room at the bottom for the legend (extra space for two rows) | |
| fig.subplots_adjust(bottom=0.16) | |
| def _compute_key_numbers(other_income, ssb, cfg, ts_plot, te_plot, | |
| torpedo_start, torpedo_end, delta=100.0): | |
| """Compute key numbers for a given income position on the analyst axis.""" | |
| my_tax = tax_with_ssb(other_income, ssb, cfg) | |
| my_gross = other_income + ssb | |
| my_take_home = my_gross - my_tax | |
| my_marginal = 100.0 * total_marginal_rate(other_income, ssb, cfg, delta=delta) | |
| my_taxable_ssb = ssb_tax(other_income, ssb, cfg) | |
| my_x_plot = other_income - cfg.standard_deduction + 0.85 * ssb | |
| my_eff = (100.0 * my_tax / other_income) if other_income > 0 else 0.0 | |
| zone = classify_zone(other_income, ssb, cfg, torpedo_start, torpedo_end) | |
| return { | |
| "tax_owed": round(my_tax, 2), | |
| "taxable_ssb": round(my_taxable_ssb, 2), | |
| "marginal_rate": round(my_marginal, 2), | |
| "effective_rate": round(my_eff, 2), | |
| "zero_point": round(ts_plot, 0) if ts_plot is not None else None, | |
| "confluence_point": round(te_plot, 0) if te_plot is not None else None, | |
| "zero_point_oi": round(torpedo_start, 0) if torpedo_start is not None else None, | |
| "confluence_point_oi": round(torpedo_end, 0) if torpedo_end is not None else None, | |
| "zone": zone, | |
| "gross_income": round(my_gross, 2), | |
| "take_home": round(my_take_home, 2), | |
| "taxable_income": round(max(0.0, my_x_plot), 2), | |
| "other_income": other_income, | |
| "filing_status": cfg.name, | |
| "ssb": ssb, | |
| } | |
| def _knee_sensitivity_lines( | |
| ssb: float, cfg: "TaxConfig", x_max: float, ssb_step: float = 5000.0 | |
| ): | |
| """ | |
| Trace the locus of the two SSB knee points as SSB varies. | |
| Starts at the user's SSB and steps upward by ssb_step until the | |
| knee's total-tax value reaches zero (i.e. the line lands in the | |
| no-tax zone). The x-axis follows the analyst convention | |
| x_plot = OI - std_ded + 0.85*SSB. | |
| Knee 1: provisional income = t1 (0% -> 50% taxable SSB) | |
| Knee 2: provisional income = t2 (50% -> 85% taxable SSB) | |
| Returns | |
| ------- | |
| k1_x, k1_y_tax, k1_y_mr – knee-1 x positions, total-tax y, marginal-rate y | |
| k2_x, k2_y_tax, k2_y_mr – same for knee 2 | |
| """ | |
| t1, t2 = cfg.ssb_thresholds.t1, cfg.ssb_thresholds.t2 | |
| def _trace(t_thresh): | |
| xs, ys_tax, ys_mr = [], [], [] | |
| ssb_k = 0 | |
| while ssb_k <= ssb + 1_000_000: | |
| oi = t_thresh - 0.5 * ssb_k | |
| xp = oi - cfg.standard_deduction + 0.85 * ssb_k | |
| if 0.0 <= xp <= x_max: | |
| y_tax = tax_with_ssb(max(0.0, oi), ssb_k, cfg) | |
| y_mr = 100.0 * total_marginal_rate(max(0.0, oi), ssb_k, cfg) | |
| xs.append(xp) | |
| ys_tax.append(y_tax) | |
| ys_mr.append(y_mr) | |
| if y_tax <= 0: | |
| break # past user's SSB and tax has hit zero – stop | |
| ssb_k += ssb_step | |
| return xs, ys_tax, ys_mr | |
| k1_x, k1_yt, k1_ym = _trace(t1) | |
| k2_x, k2_yt, k2_ym = _trace(t2) | |
| return k1_x, k1_yt, k1_ym, k2_x, k2_yt, k2_ym | |
| # --------------------------------------------------------------------------- | |
| # Plot 1: Torpedo Overview (2-panel: total tax + marginal rate) | |
| # Uses analyst x-axis: OI - Std. Ded. + 0.85*SSB | |
| # --------------------------------------------------------------------------- | |
| def generate_torpedo_plot( | |
| filing_status: str, | |
| ssb: float, | |
| other_income: float, | |
| x_max: Optional[float] = None, | |
| n: int = 800, | |
| delta: float = 100.0, | |
| ) -> Dict: | |
| """ | |
| Main torpedo visualization. 2-panel figure: | |
| Top: Total tax owed vs reference taxable income | |
| Bottom: Marginal rate vs reference taxable income | |
| X-axis: OI - Std. Ded. + 0.85*SSB ("reference taxable income") | |
| Baseline (black dashed): bracket_tax(x_plot) -- brackets alone | |
| Total (red solid): actual IRS tax with SSB torpedo | |
| Returns dict with 'image_path' and 'key_numbers'. | |
| """ | |
| cfg = CONFIGS[filing_status] | |
| if x_max is None: | |
| x_max = max(other_income * 1.5, 100000) | |
| with plt.rc_context(PLOT_STYLE): | |
| # --- Analyst x-axis convention --- | |
| x_start = cfg.standard_deduction - 0.85 * ssb | |
| x = np.linspace(x_start, x_max, n) | |
| x_plot = x - cfg.standard_deduction + 0.85 * ssb # analyst axis, starts at 0 | |
| x_clipped = np.maximum(0.0, x) # OI can't be negative | |
| # Total curve: actual OI (clipped) drives tax calculations | |
| tax_total = np.array([tax_with_ssb(xi, ssb, cfg) for xi in x_clipped], dtype=float) | |
| mr_total = np.array([100.0 * total_marginal_rate(xi, ssb, cfg, delta=delta) | |
| for xi in x_clipped], dtype=float) | |
| taxable_ssb_arr = np.array([ssb_tax(xi, ssb, cfg) for xi in x_clipped], dtype=float) | |
| # Baseline curve: bracket_tax(oa) -- "what would brackets alone give?" | |
| tax_base = np.array([bracket_tax(max(0.0, oa), cfg) for oa in x_plot], dtype=float) | |
| mr_base = np.array([100.0 * bracket_marginal_rate(oa + cfg.standard_deduction, cfg) | |
| for oa in x_plot], dtype=float) | |
| # User's point on analyst axis | |
| my_tax = tax_with_ssb(other_income, ssb, cfg) | |
| my_gross = other_income + ssb | |
| my_take_home = my_gross - my_tax | |
| my_marginal = 100.0 * total_marginal_rate(other_income, ssb, cfg, delta=delta) | |
| my_taxable_ssb = ssb_tax(other_income, ssb, cfg) | |
| my_x_plot = other_income - cfg.standard_deduction + 0.85 * ssb | |
| my_eff = (100.0 * my_tax / other_income) if other_income > 0 else 0.0 | |
| # Zone boundaries (raw OI values from find_torpedo_bounds) | |
| torpedo_start, torpedo_end = find_torpedo_bounds(cfg, ssb, x_max) | |
| zone = classify_zone(other_income, ssb, cfg, torpedo_start, torpedo_end) | |
| # Transform boundaries to analyst x-axis | |
| ts_plot = (torpedo_start - cfg.standard_deduction + 0.85 * ssb) if torpedo_start is not None else None | |
| te_plot = (torpedo_end - cfg.standard_deduction + 0.85 * ssb) if torpedo_end is not None else None | |
| # Knee sensitivity lines (green): locus of knee points as SSB varies | |
| k1_x, k1_yt, k1_ym, k2_x, k2_yt, k2_ym = _knee_sensitivity_lines(ssb, cfg, x_max) | |
| key_numbers = _compute_key_numbers( | |
| other_income, ssb, cfg, ts_plot, te_plot, | |
| torpedo_start, torpedo_end, delta, | |
| ) | |
| fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10)) | |
| # === TOP PANEL: Total Taxes Owed vs Analyst X-Axis === | |
| # Zone shading (no legend labels) | |
| _add_zone_shading(ax1, x_plot, ts_plot, te_plot) | |
| # Baseline tax line -- BLACK DASHED | |
| ax1.plot(x_plot, tax_base, color="black", linewidth=2, linestyle="--", | |
| label="Baseline Tax (no SSB)") | |
| # Total tax line -- RED SOLID | |
| ax1.plot(x_plot, tax_total, color="#e53935", linewidth=2, | |
| label="Total Tax (with SSB)") | |
| # User marker | |
| ax1.scatter(my_x_plot, my_tax, marker="*", s=500, color="red", | |
| edgecolors="white", zorder=4, label="Your Tax Owed") | |
| # Zone boundary markers | |
| if torpedo_start is not None and ts_plot is not None: | |
| tax_at_zp = tax_total[np.argmin(np.abs(x - torpedo_start))] | |
| ax1.scatter(ts_plot, tax_at_zp, marker="o", color="green", | |
| s=120, zorder=3, label="Zero Point") | |
| if torpedo_end is not None and te_plot is not None: | |
| tax_at_cp = tax_total[np.argmin(np.abs(x - torpedo_end))] | |
| ax1.scatter(te_plot, tax_at_cp, marker="D", color="orange", | |
| s=100, zorder=3, label="Confluence Point (85% cap)") | |
| # Green knee-locus lines: how the knee point moves as SSB changes | |
| if len(k1_x) > 1: | |
| ax1.plot(k1_x, k1_yt, color="green", linewidth=1.8, zorder=5, | |
| linestyle="--", label="Knee locus: 0%\u219250% taxable SSB") | |
| if len(k2_x) > 1: | |
| ax1.plot(k2_x, k2_yt, color="green", linewidth=1.8, zorder=5, | |
| linestyle="--", label="Knee locus: 50%\u219285% taxable SSB") | |
| ax1.set_xlabel(X_AXIS_LABEL) | |
| ax1.set_ylabel("Total Tax Owed ($)") | |
| ax1.set_title(f"{cfg.name}: Total Taxes Owed (SSB = ${ssb:,.0f})") | |
| ax1.xaxis.set_major_formatter(mticker.FuncFormatter(_dollar_fmt)) | |
| ax1.yaxis.set_major_formatter(mticker.FuncFormatter(_dollar_fmt)) | |
| # Zone text labels above plot | |
| _add_zone_text_labels(ax1, x_plot, ts_plot, te_plot) | |
| # === BOTTOM PANEL: Marginal Rate vs Analyst X-Axis === | |
| # Zone shading on bottom panel too | |
| _add_zone_shading(ax2, x_plot, ts_plot, te_plot) | |
| # Baseline marginal rate -- BLACK DASHED step | |
| ax2.step(x_plot, mr_base, where="post", color="black", linewidth=1.5, | |
| linestyle="--", label="Baseline Marginal Rate (no SSB)") | |
| # Total marginal rate -- RED SOLID | |
| ax2.plot(x_plot, mr_total, color="#e53935", linewidth=2, | |
| label="Marginal Rate (with SSB)") | |
| # User marker | |
| ax2.scatter(my_x_plot, my_marginal, marker="*", s=500, color="red", | |
| edgecolors="white", zorder=3, label="Your Position") | |
| ax2.set_xlabel(X_AXIS_LABEL) | |
| ax2.set_ylabel("Marginal Tax Rate (%)") | |
| ax2.xaxis.set_major_formatter(mticker.FuncFormatter(_dollar_fmt)) | |
| ax2.yaxis.set_major_formatter(mticker.FuncFormatter(_pct_fmt)) | |
| ax2.set_ylim(0, max(mr_total) * 1.05 if max(mr_total) > 0 else 50) | |
| # Zone text labels above bottom panel | |
| _add_zone_text_labels(ax2, x_plot, ts_plot, te_plot) | |
| # Taxable SSB overlay on right axis | |
| ax2b = ax2.twinx() | |
| ax2b.plot(x_plot, taxable_ssb_arr, linestyle="--", alpha=0.25, color="gray", | |
| label="Taxable SSB ($)") | |
| ax2b.set_ylabel("Taxable SSB ($)", fontsize=12, alpha=0.5) | |
| ax2b.yaxis.set_major_formatter(mticker.FuncFormatter(_dollar_fmt)) | |
| # Collect twin-axis handles for legend | |
| twin_handles, twin_labels = ax2b.get_legend_handles_labels() | |
| ax2b_legend = ax2b.get_legend() | |
| if ax2b_legend: | |
| ax2b_legend.remove() | |
| # Single legend row below charts | |
| _add_legend_below(fig, [ax1, ax2], extra_handles=twin_handles, extra_labels=twin_labels) | |
| plt.tight_layout() | |
| fig.subplots_adjust(bottom=0.16) | |
| path = _save_fig(fig, "torpedo") | |
| return {"image_path": path, "key_numbers": key_numbers} | |
| # --------------------------------------------------------------------------- | |
| # Plot 1B: Scenario Comparison ON the Torpedo Curve | |
| # Now supports MULTIPLE new positions (shown in different colors). | |
| # --------------------------------------------------------------------------- | |
| def generate_scenario_torpedo_plot( | |
| filing_status: str, | |
| ssb: float, | |
| old_other_income: float, | |
| new_other_incomes: list | float, | |
| scenario_labels: list | None = None, | |
| x_max: Optional[float] = None, | |
| n: int = 800, | |
| delta: float = 100.0, | |
| ) -> Dict: | |
| """ | |
| Scenario comparison overlaid on the torpedo curve. | |
| Shows OLD position (red star) and one or more NEW positions (colored | |
| squares) with arrows connecting them and delta annotations. | |
| *new_other_incomes* can be a single float or a list of floats. | |
| *scenario_labels* optional list of labels for each new position. | |
| Returns dict with 'image_path', 'old_key_numbers', 'new_key_numbers'. | |
| When multiple new positions, 'new_key_numbers' is a list. | |
| """ | |
| # Normalise inputs | |
| if isinstance(new_other_incomes, (int, float)): | |
| new_other_incomes = [float(new_other_incomes)] | |
| else: | |
| new_other_incomes = [float(v) for v in new_other_incomes] | |
| if scenario_labels is None: | |
| if len(new_other_incomes) == 1: | |
| scenario_labels = ["New Scenario"] | |
| else: | |
| scenario_labels = [f"Scenario {chr(65+i)}: OI=${v:,.0f}" | |
| for i, v in enumerate(new_other_incomes)] | |
| cfg = CONFIGS[filing_status] | |
| if x_max is None: | |
| all_oi = [old_other_income] + list(new_other_incomes) | |
| x_max = max(max(all_oi) * 1.5, 100000) | |
| with plt.rc_context(PLOT_STYLE): | |
| # --- Analyst x-axis convention --- | |
| x_start = cfg.standard_deduction - 0.85 * ssb | |
| x = np.linspace(x_start, x_max, n) | |
| x_plot = x - cfg.standard_deduction + 0.85 * ssb | |
| x_clipped = np.maximum(0.0, x) | |
| # Curves | |
| tax_total = np.array([tax_with_ssb(xi, ssb, cfg) for xi in x_clipped], dtype=float) | |
| mr_total = np.array([100.0 * total_marginal_rate(xi, ssb, cfg, delta=delta) | |
| for xi in x_clipped], dtype=float) | |
| tax_base = np.array([bracket_tax(max(0.0, oa), cfg) for oa in x_plot], dtype=float) | |
| mr_base = np.array([100.0 * bracket_marginal_rate(oa + cfg.standard_deduction, cfg) | |
| for oa in x_plot], dtype=float) | |
| taxable_ssb_arr = np.array([ssb_tax(xi, ssb, cfg) for xi in x_clipped], dtype=float) | |
| # Zone boundaries | |
| torpedo_start, torpedo_end = find_torpedo_bounds(cfg, ssb, x_max) | |
| ts_plot = (torpedo_start - cfg.standard_deduction + 0.85 * ssb) if torpedo_start is not None else None | |
| te_plot = (torpedo_end - cfg.standard_deduction + 0.85 * ssb) if torpedo_end is not None else None | |
| # Knee sensitivity lines (green) | |
| k1_x, k1_yt, k1_ym, k2_x, k2_yt, k2_ym = _knee_sensitivity_lines(ssb, cfg, x_max) | |
| # Key numbers for old position | |
| old_kn = _compute_key_numbers( | |
| old_other_income, ssb, cfg, ts_plot, te_plot, | |
| torpedo_start, torpedo_end, delta, | |
| ) | |
| # Key numbers for each new position | |
| new_kns = [] | |
| for i, noi in enumerate(new_other_incomes): | |
| kn = _compute_key_numbers( | |
| noi, ssb, cfg, ts_plot, te_plot, | |
| torpedo_start, torpedo_end, delta, | |
| ) | |
| kn["scenario_label"] = scenario_labels[i] | |
| kn["scenario_color"] = _SCENARIO_COLORS[i % len(_SCENARIO_COLORS)] | |
| new_kns.append(kn) | |
| # Analyst-axis positions | |
| old_x_plot = old_other_income - cfg.standard_deduction + 0.85 * ssb | |
| old_tax = tax_with_ssb(old_other_income, ssb, cfg) | |
| old_mr = 100.0 * total_marginal_rate(old_other_income, ssb, cfg, delta=delta) | |
| fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10)) | |
| # === TOP PANEL === | |
| _add_zone_shading(ax1, x_plot, ts_plot, te_plot) | |
| ax1.plot(x_plot, tax_base, color="black", linewidth=2, linestyle="--", | |
| label="Baseline Tax (no SSB)") | |
| ax1.plot(x_plot, tax_total, color="#e53935", linewidth=2, | |
| label="Total Tax (with SSB)") | |
| # Old position (red star) | |
| ax1.scatter(old_x_plot, old_tax, marker="*", s=500, color="red", | |
| edgecolors="white", zorder=4, label="Current Position") | |
| # Zone boundary markers | |
| if ts_plot is not None: | |
| tax_at_zp = tax_total[np.argmin(np.abs(x - torpedo_start))] | |
| ax1.scatter(ts_plot, tax_at_zp, marker="o", color="green", | |
| s=120, zorder=3, label="Zero Point") | |
| if te_plot is not None: | |
| tax_at_cp = tax_total[np.argmin(np.abs(x - torpedo_end))] | |
| ax1.scatter(te_plot, tax_at_cp, marker="D", color="orange", | |
| s=100, zorder=3, label="Confluence Point") | |
| # New positions (colored squares) | |
| for i, (noi, lbl) in enumerate(zip(new_other_incomes, scenario_labels)): | |
| color = _SCENARIO_COLORS[i % len(_SCENARIO_COLORS)] | |
| new_x = noi - cfg.standard_deduction + 0.85 * ssb | |
| new_tax_val = tax_with_ssb(noi, ssb, cfg) | |
| ax1.scatter(new_x, new_tax_val, marker="s", s=300, color=color, | |
| edgecolors="white", zorder=4, label=lbl) | |
| # Arrow from old to new | |
| ax1.annotate("", xy=(new_x, new_tax_val), xytext=(old_x_plot, old_tax), | |
| arrowprops=dict(arrowstyle="-|>", color=color, lw=2.5)) | |
| # Delta annotation | |
| delta_tax = new_tax_val - old_tax | |
| sign = "+" if delta_tax >= 0 else "" | |
| mid_x = (old_x_plot + new_x) / 2 | |
| mid_y = (old_tax + new_tax_val) / 2 | |
| delta_color = "#c62828" if delta_tax > 0 else "#2e7d32" | |
| # Offset labels vertically when there are multiple scenarios | |
| offset = 0 | |
| if len(new_other_incomes) > 1: | |
| offset = (i - (len(new_other_incomes) - 1) / 2) * (max(tax_total) * 0.06) | |
| ax1.text(mid_x, mid_y + offset, f"{sign}${delta_tax:,.0f} tax", | |
| fontsize=13, fontweight="bold", color=delta_color, | |
| ha="center", va="bottom", | |
| bbox=dict(facecolor="white", alpha=0.85, edgecolor=color, | |
| boxstyle="round,pad=0.3")) | |
| # Green knee-locus lines (top panel) | |
| if len(k1_x) > 1: | |
| ax1.plot(k1_x, k1_yt, color="green", linewidth=1.8, zorder=5, | |
| linestyle="--",label="Knee locus: 0%\u219250% taxable SSB") | |
| if len(k2_x) > 1: | |
| ax1.plot(k2_x, k2_yt, color="green", linewidth=1.8, zorder=5, | |
| linestyle="--", label="Knee locus: 50%\u219285% taxable SSB") | |
| ax1.set_xlabel(X_AXIS_LABEL) | |
| ax1.set_ylabel("Total Tax Owed ($)") | |
| ax1.set_title(f"{cfg.name}: Scenario Comparison (SSB = ${ssb:,.0f})") | |
| ax1.xaxis.set_major_formatter(mticker.FuncFormatter(_dollar_fmt)) | |
| ax1.yaxis.set_major_formatter(mticker.FuncFormatter(_dollar_fmt)) | |
| _add_zone_text_labels(ax1, x_plot, ts_plot, te_plot) | |
| # === BOTTOM PANEL === | |
| _add_zone_shading(ax2, x_plot, ts_plot, te_plot) | |
| ax2.step(x_plot, mr_base, where="post", color="black", linewidth=1.5, | |
| linestyle="--", label="Baseline Marginal Rate (no SSB)") | |
| ax2.plot(x_plot, mr_total, color="#e53935", linewidth=2, | |
| label="Marginal Rate (with SSB)") | |
| # Old position (red star) | |
| ax2.scatter(old_x_plot, old_mr, marker="*", s=500, color="red", | |
| edgecolors="white", zorder=3, label="Current Position") | |
| # New positions (colored squares) | |
| for i, (noi, lbl) in enumerate(zip(new_other_incomes, scenario_labels)): | |
| color = _SCENARIO_COLORS[i % len(_SCENARIO_COLORS)] | |
| new_x = noi - cfg.standard_deduction + 0.85 * ssb | |
| new_mr_val = 100.0 * total_marginal_rate(noi, ssb, cfg, delta=delta) | |
| ax2.scatter(new_x, new_mr_val, marker="s", s=300, color=color, | |
| edgecolors="white", zorder=3, label=lbl) | |
| # Arrow | |
| ax2.annotate("", xy=(new_x, new_mr_val), xytext=(old_x_plot, old_mr), | |
| arrowprops=dict(arrowstyle="-|>", color=color, lw=2.5)) | |
| # Delta annotation | |
| delta_mr_val = new_mr_val - old_mr | |
| sign_mr = "+" if delta_mr_val >= 0 else "" | |
| mid_x_mr = (old_x_plot + new_x) / 2 | |
| mid_y_mr = (old_mr + new_mr_val) / 2 | |
| delta_mr_color = "#c62828" if delta_mr_val > 0 else "#2e7d32" | |
| offset = 0 | |
| if len(new_other_incomes) > 1: | |
| offset = (i - (len(new_other_incomes) - 1) / 2) * 3 | |
| ax2.text(mid_x_mr, mid_y_mr + offset, f"{sign_mr}{delta_mr_val:.1f}% rate", | |
| fontsize=13, fontweight="bold", color=delta_mr_color, | |
| ha="center", va="bottom", | |
| bbox=dict(facecolor="white", alpha=0.85, edgecolor=color, | |
| boxstyle="round,pad=0.3")) | |
| ax2.set_xlabel(X_AXIS_LABEL) | |
| ax2.set_ylabel("Marginal Tax Rate (%)") | |
| ax2.xaxis.set_major_formatter(mticker.FuncFormatter(_dollar_fmt)) | |
| ax2.yaxis.set_major_formatter(mticker.FuncFormatter(_pct_fmt)) | |
| ax2.set_ylim(0, max(mr_total) * 1.05 if max(mr_total) > 0 else 50) | |
| _add_zone_text_labels(ax2, x_plot, ts_plot, te_plot) | |
| # Taxable SSB overlay | |
| ax2b = ax2.twinx() | |
| ax2b.plot(x_plot, taxable_ssb_arr, linestyle="--", alpha=0.25, color="gray", | |
| label="Taxable SSB ($)") | |
| ax2b.set_ylabel("Taxable SSB ($)", fontsize=12, alpha=0.5) | |
| ax2b.yaxis.set_major_formatter(mticker.FuncFormatter(_dollar_fmt)) | |
| twin_handles, twin_labels = ax2b.get_legend_handles_labels() | |
| ax2b_legend = ax2b.get_legend() | |
| if ax2b_legend: | |
| ax2b_legend.remove() | |
| # Single legend row below charts | |
| _add_legend_below(fig, [ax1, ax2], extra_handles=twin_handles, extra_labels=twin_labels) | |
| plt.tight_layout() | |
| fig.subplots_adjust(bottom=0.16) | |
| path = _save_fig(fig, "scenario_torpedo") | |
| # Return format: if single scenario, new_key_numbers is a dict; | |
| # if multiple, it's a list. Also include 'all_new_key_numbers' always as list. | |
| result = { | |
| "image_path": path, | |
| "old_key_numbers": old_kn, | |
| "all_new_key_numbers": new_kns, | |
| } | |
| if len(new_kns) == 1: | |
| result["new_key_numbers"] = new_kns[0] | |
| else: | |
| result["new_key_numbers"] = new_kns[0] # first scenario for panel display | |
| return result | |
| # --------------------------------------------------------------------------- | |
| # Plot 2: Scenario Comparison (grouped bar chart) | |
| # --------------------------------------------------------------------------- | |
| def generate_scenario_comparison( | |
| filing_status: str, | |
| ssb: float, | |
| scenarios: List[Dict], | |
| ) -> Dict: | |
| """ | |
| Compare 2-4 income scenarios side by side. | |
| scenarios: list of dicts with 'label' and 'other_income' keys. | |
| Returns dict with 'scenario_results' and 'image_path'. | |
| """ | |
| cfg = CONFIGS[filing_status] | |
| results = [] | |
| for sc in scenarios: | |
| oi = sc["other_income"] | |
| detail = tax_with_ssb_detail(oi, ssb, cfg) | |
| baseline = compute_baseline_tax(oi, cfg) | |
| ssb_driven = detail["tax"] - baseline | |
| mr = total_marginal_rate(oi, ssb, cfg) | |
| zp, cp = find_torpedo_bounds(cfg, ssb) | |
| zone = classify_zone(oi, ssb, cfg, zp, cp) | |
| results.append({ | |
| "label": sc["label"], | |
| "other_income": oi, | |
| "gross_income": oi + ssb, | |
| "tax_owed": round(detail["tax"], 2), | |
| "regular_tax": round(max(0, baseline), 2), | |
| "ssb_driven_tax": round(max(0, ssb_driven), 2), | |
| "take_home": round(oi + ssb - detail["tax"], 2), | |
| "marginal_rate": round(mr * 100, 2), | |
| "effective_rate": round(detail["effective_rate"], 2), | |
| "zone": zone, | |
| }) | |
| with plt.rc_context(PLOT_STYLE): | |
| labels = [r["label"] for r in results] | |
| take_homes = [r["take_home"] for r in results] | |
| reg_taxes = [r["regular_tax"] for r in results] | |
| ssb_taxes = [r["ssb_driven_tax"] for r in results] | |
| x_pos = np.arange(len(labels)) | |
| width = 0.55 | |
| fig, ax = plt.subplots(figsize=(max(10, len(labels) * 3), 7)) | |
| bars_take = ax.bar(x_pos, take_homes, width, label="Take-Home Income", | |
| color="#4CAF50", edgecolor="white") | |
| bars_reg = ax.bar(x_pos, reg_taxes, width, bottom=take_homes, | |
| label="Regular Taxes", color="#c3e3f7", edgecolor="white") | |
| bottoms = [t + r for t, r in zip(take_homes, reg_taxes)] | |
| bars_ssb = ax.bar(x_pos, ssb_taxes, width, bottom=bottoms, | |
| label="SSB-Driven Taxes", color="#f7dfc3", edgecolor="white") | |
| # Annotate bars | |
| for i, r in enumerate(results): | |
| # Take-home amount | |
| ax.text(i, r["take_home"] / 2, f"${r['take_home']:,.0f}", | |
| ha="center", va="center", fontsize=13, fontweight="bold", color="white") | |
| # Total tax | |
| total_tax = r["regular_tax"] + r["ssb_driven_tax"] | |
| if total_tax > 0: | |
| ax.text(i, r["take_home"] + total_tax / 2, f"Tax: ${total_tax:,.0f}", | |
| ha="center", va="center", fontsize=11, color="#333") | |
| # Zone badge at top | |
| zone_color = ZONE_COLORS.get(r["zone"], ("#eee", "#333")) | |
| ax.text(i, r["gross_income"] + r["gross_income"] * 0.02, | |
| r["zone"], ha="center", va="bottom", fontsize=11, | |
| fontweight="bold", color=zone_color[1], | |
| bbox=dict(boxstyle="round,pad=0.3", facecolor=zone_color[0], alpha=0.8)) | |
| ax.set_ylabel("Dollars ($)") | |
| ax.set_title(f"Scenario Comparison (SSB = ${ssb:,.0f})") | |
| ax.set_xticks(x_pos) | |
| ax.set_xticklabels(labels, fontsize=14) | |
| ax.yaxis.set_major_formatter(mticker.FuncFormatter(_dollar_fmt)) | |
| _add_legend_below(fig, [ax]) | |
| plt.tight_layout() | |
| fig.subplots_adjust(bottom=0.16) | |
| path = _save_fig(fig, "scenarios") | |
| return {"scenario_results": results, "image_path": path} | |
| # --------------------------------------------------------------------------- | |
| # Plot 3: Educational Concept Diagrams | |
| # --------------------------------------------------------------------------- | |
| def generate_concept_diagram( | |
| concept: str, | |
| user_ssb: Optional[float] = None, | |
| user_income: Optional[float] = None, | |
| filing_status: str = "MFJ", | |
| ) -> Dict: | |
| """ | |
| Generate a dynamic educational diagram using the user's actual numbers. | |
| Returns dict with 'explanation_text' and 'diagram_path'. | |
| """ | |
| cfg = CONFIGS.get(filing_status, CONFIGS["MFJ"]) | |
| ssb = user_ssb or 30000 | |
| income = user_income or 40000 | |
| if concept == "tax_torpedo": | |
| return _diagram_tax_torpedo(cfg, ssb, income) | |
| elif concept == "provisional_income": | |
| return _diagram_provisional_income(cfg, ssb, income) | |
| elif concept == "roth_conversion": | |
| return _diagram_roth_conversion(cfg, ssb, income) | |
| elif concept == "rmd": | |
| return _diagram_rmd() | |
| elif concept == "marginal_vs_effective_rate": | |
| return _diagram_marginal_vs_effective(cfg, ssb, income) | |
| elif concept == "tax_zones": | |
| return _diagram_tax_zones(cfg, ssb, income) | |
| elif concept == "ssb_taxation_rules": | |
| return _diagram_ssb_rules(cfg, ssb) | |
| else: | |
| return {"explanation_text": f"Unknown concept: {concept}", "diagram_path": ""} | |
| def _diagram_tax_torpedo(cfg, ssb, income): | |
| """Simplified marginal rate chart with big 'TAX TORPEDO' annotation.""" | |
| with plt.rc_context(PLOT_STYLE): | |
| x = np.linspace(0, max(income * 2, 100000), 600) | |
| mr_base = np.array([100.0 * bracket_marginal_rate(xi, cfg) for xi in x]) | |
| mr_total = np.array([100.0 * total_marginal_rate(xi, ssb, cfg) for xi in x]) | |
| fig, ax = plt.subplots(figsize=(14, 7)) | |
| ax.step(x, mr_base, where="post", color="#90CAF9", linewidth=2, | |
| label="Normal Tax Rate") | |
| ax.plot(x, mr_total, color="#e53935", linewidth=3, | |
| label="Your Actual Tax Rate (with SS)") | |
| ax.fill_between(x, mr_base, mr_total, | |
| where=(mr_total > mr_base + 1), | |
| alpha=0.3, color="#e53935") | |
| # Find torpedo peak for annotation | |
| peak_idx = np.argmax(mr_total) | |
| peak_x = x[peak_idx] | |
| peak_y = mr_total[peak_idx] | |
| ax.annotate( | |
| "THE TAX TORPEDO\nYour rate spikes here!", | |
| xy=(peak_x, peak_y), | |
| xytext=(peak_x + (x[-1] - x[0]) * 0.15, peak_y + 5), | |
| fontsize=18, fontweight="bold", color="#c62828", | |
| arrowprops=dict(arrowstyle="->", color="#c62828", lw=3), | |
| bbox=dict(boxstyle="round,pad=0.5", facecolor="#ffcdd2", alpha=0.9), | |
| ) | |
| ax.set_xlabel("Other Income ($)") | |
| ax.set_ylabel("Marginal Tax Rate (%)") | |
| ax.set_title("The Tax Torpedo: Hidden Tax Rate Spike on Social Security") | |
| ax.xaxis.set_major_formatter(mticker.FuncFormatter(_dollar_fmt)) | |
| ax.yaxis.set_major_formatter(mticker.FuncFormatter(_pct_fmt)) | |
| ax.legend(fontsize=14) | |
| plt.tight_layout() | |
| path = _save_fig(fig, "concept_torpedo") | |
| return { | |
| "explanation_text": ( | |
| "The 'Tax Torpedo' is a hidden tax rate spike that hits people " | |
| "receiving Social Security. As your other income rises, more of " | |
| "your Social Security becomes taxable -- on top of the normal tax " | |
| "on that income. This can push your real tax rate much higher than " | |
| "the bracket you're officially in." | |
| ), | |
| "diagram_path": path, | |
| } | |
| def _diagram_provisional_income(cfg, ssb, income): | |
| """Flow diagram showing how provisional income is calculated.""" | |
| pi = income + 0.5 * ssb | |
| t1, t2 = cfg.ssb_thresholds.t1, cfg.ssb_thresholds.t2 | |
| with plt.rc_context(PLOT_STYLE): | |
| fig, ax = plt.subplots(figsize=(12, 6)) | |
| ax.set_xlim(0, 10) | |
| ax.set_ylim(0, 6) | |
| ax.axis("off") | |
| # Boxes | |
| boxes = [ | |
| (1, 4.5, f"Your Other Income\n${income:,.0f}", "#90CAF9"), | |
| (5, 4.5, f"50% of Social Security\n${0.5 * ssb:,.0f}", "#81C784"), | |
| (3, 2.5, f"Provisional Income\n${pi:,.0f}", "#FFE082"), | |
| ] | |
| for bx, by, text, color in boxes: | |
| ax.add_patch(plt.Rectangle((bx - 1.2, by - 0.6), 2.4, 1.2, | |
| facecolor=color, edgecolor="#333", linewidth=2, zorder=2, | |
| transform=ax.transData)) | |
| ax.text(bx, by, text, ha="center", va="center", fontsize=14, | |
| fontweight="bold", zorder=3) | |
| # Arrows | |
| ax.annotate("", xy=(2.5, 3.1), xytext=(1.5, 3.9), | |
| arrowprops=dict(arrowstyle="->", lw=2.5, color="#333")) | |
| ax.annotate("", xy=(3.5, 3.1), xytext=(5, 3.9), | |
| arrowprops=dict(arrowstyle="->", lw=2.5, color="#333")) | |
| ax.text(3, 3.5, "+", fontsize=24, fontweight="bold", ha="center", va="center") | |
| # Threshold info | |
| if pi <= t1: | |
| result_text = f"PI (${pi:,.0f}) is below ${t1:,.0f}\n0% of SS is taxable" | |
| result_color = "#c8e6c9" | |
| elif pi <= t2: | |
| result_text = f"PI (${pi:,.0f}) is between ${t1:,.0f} and ${t2:,.0f}\nUp to 50% of SS is taxable" | |
| result_color = "#fff9c4" | |
| else: | |
| result_text = f"PI (${pi:,.0f}) is above ${t2:,.0f}\nUp to 85% of SS is taxable" | |
| result_color = "#ffcdd2" | |
| ax.add_patch(plt.Rectangle((1, 0.2), 6, 1.0, | |
| facecolor=result_color, edgecolor="#333", linewidth=2, zorder=2)) | |
| ax.text(4, 0.7, result_text, ha="center", va="center", fontsize=14, | |
| fontweight="bold", zorder=3) | |
| ax.set_title("How Provisional Income Determines Your SSB Taxation", fontsize=18, pad=20) | |
| plt.tight_layout() | |
| path = _save_fig(fig, "concept_pi") | |
| return { | |
| "explanation_text": ( | |
| f"Provisional Income = Your Other Income + half of your Social Security. " | |
| f"Yours is ${income:,.0f} + ${0.5*ssb:,.0f} = ${pi:,.0f}. " | |
| f"The IRS uses this number to decide how much of your Social Security is taxable." | |
| ), | |
| "diagram_path": path, | |
| } | |
| def _diagram_roth_conversion(cfg, ssb, income): | |
| """Visual showing Roth conversion impact.""" | |
| with plt.rc_context(PLOT_STYLE): | |
| conversions = [0, 5000, 10000, 20000, 30000, 50000] | |
| taxes = [tax_with_ssb(income + c, ssb, cfg) for c in conversions] | |
| base_tax = taxes[0] | |
| costs = [t - base_tax for t in taxes] | |
| fig, ax = plt.subplots(figsize=(12, 6)) | |
| bars = ax.bar([f"${c:,.0f}" for c in conversions], costs, | |
| color=["#4CAF50" if c < 2000 else "#FFB74D" if c < 5000 else "#ef5350" | |
| for c in costs], | |
| edgecolor="white", linewidth=2) | |
| for bar, cost in zip(bars, costs): | |
| ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 50, | |
| f"${cost:,.0f}", ha="center", fontsize=13, fontweight="bold") | |
| ax.set_xlabel("Roth Conversion Amount") | |
| ax.set_ylabel("Additional Tax Cost ($)") | |
| ax.set_title("Tax Cost of Different Roth Conversion Amounts") | |
| ax.yaxis.set_major_formatter(mticker.FuncFormatter(_dollar_fmt)) | |
| plt.tight_layout() | |
| path = _save_fig(fig, "concept_roth") | |
| return { | |
| "explanation_text": ( | |
| "A Roth conversion moves money from a Traditional IRA (taxed when withdrawn) " | |
| "to a Roth IRA (tax-free when withdrawn). You pay tax now on the converted amount, " | |
| "but never again. The key is finding how much you can convert before hitting " | |
| "the expensive tax torpedo zone." | |
| ), | |
| "diagram_path": path, | |
| } | |
| def _diagram_rmd(): | |
| """Simple RMD explanation.""" | |
| with plt.rc_context(PLOT_STYLE): | |
| ages = list(range(73, 96)) | |
| from rmd_tables import UNIFORM_LIFETIME_TABLE | |
| periods = [UNIFORM_LIFETIME_TABLE.get(a, 2.0) for a in ages] | |
| pcts = [100.0 / p for p in periods] | |
| fig, ax = plt.subplots(figsize=(12, 6)) | |
| ax.bar(ages, pcts, color="#42A5F5", edgecolor="white") | |
| for i, (age, pct) in enumerate(zip(ages, pcts)): | |
| if i % 3 == 0: | |
| ax.text(age, pct + 0.1, f"{pct:.1f}%", ha="center", fontsize=10) | |
| ax.set_xlabel("Age") | |
| ax.set_ylabel("RMD as % of Balance") | |
| ax.set_title("Required Minimum Distributions: Percentage Increases with Age") | |
| ax.yaxis.set_major_formatter(mticker.FuncFormatter(_pct_fmt)) | |
| plt.tight_layout() | |
| path = _save_fig(fig, "concept_rmd") | |
| return { | |
| "explanation_text": ( | |
| "Starting at age 73, the IRS requires you to withdraw a minimum amount " | |
| "from your Traditional IRA, 401(k), and 403(b) each year. This is called " | |
| "a Required Minimum Distribution (RMD). The percentage you must withdraw " | |
| "increases as you age -- starting around 3.6% at 73 and rising each year." | |
| ), | |
| "diagram_path": path, | |
| } | |
| def _diagram_marginal_vs_effective(cfg, ssb, income): | |
| """Show difference between marginal and effective rate.""" | |
| with plt.rc_context(PLOT_STYLE): | |
| x = np.linspace(0, max(income * 2, 100000), 500) | |
| marginals = np.array([100.0 * total_marginal_rate(xi, ssb, cfg) for xi in x]) | |
| effectives = np.array([ | |
| 100.0 * tax_with_ssb(xi, ssb, cfg) / xi if xi > 0 else 0 for xi in x | |
| ]) | |
| fig, ax = plt.subplots(figsize=(12, 6)) | |
| ax.plot(x, marginals, color="#e53935", linewidth=2.5, label="Marginal Rate (next dollar)") | |
| ax.plot(x, effectives, color="#1565c0", linewidth=2.5, label="Effective Rate (overall average)") | |
| my_marginal = 100.0 * total_marginal_rate(income, ssb, cfg) | |
| my_effective = 100.0 * tax_with_ssb(income, ssb, cfg) / income if income > 0 else 0 | |
| ax.scatter([income], [my_marginal], s=200, color="#e53935", zorder=5, edgecolors="white") | |
| ax.scatter([income], [my_effective], s=200, color="#1565c0", zorder=5, edgecolors="white") | |
| ax.annotate(f"Your marginal: {my_marginal:.1f}%", | |
| xy=(income, my_marginal), xytext=(income + income * 0.1, my_marginal + 3), | |
| fontsize=13, arrowprops=dict(arrowstyle="->", color="#e53935"), | |
| color="#e53935", fontweight="bold") | |
| ax.annotate(f"Your effective: {my_effective:.1f}%", | |
| xy=(income, my_effective), xytext=(income + income * 0.1, my_effective - 5), | |
| fontsize=13, arrowprops=dict(arrowstyle="->", color="#1565c0"), | |
| color="#1565c0", fontweight="bold") | |
| ax.set_xlabel("Other Income ($)") | |
| ax.set_ylabel("Tax Rate (%)") | |
| ax.set_title("Marginal vs. Effective Tax Rate") | |
| ax.xaxis.set_major_formatter(mticker.FuncFormatter(_dollar_fmt)) | |
| ax.yaxis.set_major_formatter(mticker.FuncFormatter(_pct_fmt)) | |
| ax.legend(fontsize=14) | |
| plt.tight_layout() | |
| path = _save_fig(fig, "concept_rates") | |
| return { | |
| "explanation_text": ( | |
| f"Your MARGINAL rate ({my_marginal:.1f}%) is the tax on your next dollar of income. " | |
| f"Your EFFECTIVE rate ({my_effective:.1f}%) is the average rate on all your income. " | |
| "The marginal rate matters most for decisions about withdrawals and conversions." | |
| ), | |
| "diagram_path": path, | |
| } | |
| def _diagram_tax_zones(cfg, ssb, income): | |
| """Annotated zone diagram.""" | |
| x_max = max(income * 2, 100000) | |
| zp, cp = find_torpedo_bounds(cfg, ssb, x_max) | |
| with plt.rc_context(PLOT_STYLE): | |
| x = np.linspace(0, x_max, 600) | |
| mr = np.array([100.0 * total_marginal_rate(xi, ssb, cfg) for xi in x]) | |
| fig, ax = plt.subplots(figsize=(14, 7)) | |
| if zp is not None: | |
| ax.axvspan(0, zp, color="green", alpha=0.15) | |
| ax.text(zp / 2, max(mr) * 0.85, "NO-TAX\nZONE", | |
| ha="center", fontsize=20, fontweight="bold", color="#2e7d32", | |
| bbox=dict(boxstyle="round", facecolor="white", alpha=0.8)) | |
| if zp is not None and cp is not None: | |
| ax.axvspan(zp, cp, color="red", alpha=0.12) | |
| ax.text((zp + cp) / 2, max(mr) * 0.85, "HIGH-TAX\nZONE\n(Tax Torpedo!)", | |
| ha="center", fontsize=20, fontweight="bold", color="#c62828", | |
| bbox=dict(boxstyle="round", facecolor="white", alpha=0.8)) | |
| if cp is not None: | |
| ax.axvspan(cp, x_max, color="blue", alpha=0.08) | |
| ax.text((cp + x_max) / 2, max(mr) * 0.85, "SAME-OLD\nZONE", | |
| ha="center", fontsize=20, fontweight="bold", color="#1565c0", | |
| bbox=dict(boxstyle="round", facecolor="white", alpha=0.8)) | |
| ax.plot(x, mr, color="#333", linewidth=2.5) | |
| # Mark user | |
| my_mr = 100.0 * total_marginal_rate(income, ssb, cfg) | |
| ax.scatter([income], [my_mr], s=400, color="red", marker="*", | |
| edgecolors="white", zorder=5) | |
| ax.annotate("YOU ARE HERE", xy=(income, my_mr), | |
| xytext=(income, my_mr + 5), fontsize=16, fontweight="bold", | |
| color="red", ha="center") | |
| ax.set_xlabel("Other Income ($)") | |
| ax.set_ylabel("Marginal Tax Rate (%)") | |
| ax.set_title("The Three Tax Zones") | |
| ax.xaxis.set_major_formatter(mticker.FuncFormatter(_dollar_fmt)) | |
| ax.yaxis.set_major_formatter(mticker.FuncFormatter(_pct_fmt)) | |
| plt.tight_layout() | |
| path = _save_fig(fig, "concept_zones") | |
| return { | |
| "explanation_text": ( | |
| "There are three tax zones: " | |
| "The GREEN No-Tax Zone (your income is low enough that you owe no federal tax). " | |
| "The RED High-Tax Zone (the 'torpedo' -- your Social Security is being taxed " | |
| "at accelerated rates). " | |
| "The BLUE Same-Old Zone (past the torpedo -- normal tax rates apply)." | |
| ), | |
| "diagram_path": path, | |
| } | |
| def _diagram_ssb_rules(cfg, ssb): | |
| """Visual showing the 3-tier SSB taxation rules.""" | |
| t1 = cfg.ssb_thresholds.t1 | |
| t2 = cfg.ssb_thresholds.t2 | |
| with plt.rc_context(PLOT_STYLE): | |
| x = np.linspace(0, t2 * 2.5, 500) | |
| pi = x + 0.5 * ssb | |
| taxable = np.array([ssb_tax(xi, ssb, cfg) for xi in x]) | |
| pct_taxable = taxable / ssb * 100 | |
| fig, ax = plt.subplots(figsize=(12, 6)) | |
| ax.plot(x, pct_taxable, color="#e53935", linewidth=3) | |
| # Annotate tiers | |
| ax.axvline(t1 - 0.5 * ssb, linestyle="--", color="green", linewidth=2) | |
| ax.axvline(t2 - 0.5 * ssb, linestyle="--", color="orange", linewidth=2) | |
| ax.axhline(50, linestyle=":", color="gray", alpha=0.5) | |
| ax.axhline(85, linestyle=":", color="gray", alpha=0.5) | |
| ax.text(0, 2, "Tier 1: 0% Taxable", fontsize=14, color="#2e7d32", fontweight="bold") | |
| tier2_x = max(0, t1 - 0.5 * ssb) | |
| ax.text(tier2_x + 1000, 30, "Tier 2: Up to 50%", fontsize=14, | |
| color="#F57F17", fontweight="bold") | |
| tier3_x = max(0, t2 - 0.5 * ssb) | |
| ax.text(tier3_x + 1000, 70, "Tier 3: Up to 85%", fontsize=14, | |
| color="#c62828", fontweight="bold") | |
| ax.set_xlabel("Other Income ($)") | |
| ax.set_ylabel("% of Social Security That Is Taxable") | |
| ax.set_title(f"Social Security Taxation Rules ({cfg.name})") | |
| ax.xaxis.set_major_formatter(mticker.FuncFormatter(_dollar_fmt)) | |
| ax.yaxis.set_major_formatter(mticker.FuncFormatter(_pct_fmt)) | |
| ax.set_ylim(-5, 100) | |
| plt.tight_layout() | |
| path = _save_fig(fig, "concept_ssb_rules") | |
| return { | |
| "explanation_text": ( | |
| "The IRS taxes your Social Security in three tiers based on your " | |
| "'Provisional Income' (other income + half of SS): " | |
| f"Below ${t1:,.0f}: 0% taxable. " | |
| f"${t1:,.0f} to ${t2:,.0f}: up to 50% taxable. " | |
| f"Above ${t2:,.0f}: up to 85% taxable. " | |
| "The maximum is 85% -- the IRS never taxes more than 85% of your SS." | |
| ), | |
| "diagram_path": path, | |
| } | |