#!/usr/bin/env python3 """ FIRE / Coast-FIRE projection with a realistic rising-income career arc. Key upgrade over retirement_projection.py: income is NOT flat. It follows a career arc, and savings is a PERCENTAGE of income — so both your nest egg AND your lifestyle scale as you earn more. This is what lets us show the real trade: a high savings rate hits FIRE faster BOTH because you save more AND because you need less (lower spending => lower 25x FIRE number). All figures in TODAY'S DOLLARS (real returns). /tmp/retire-venv/bin/python fire_projection.py """ import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib.ticker import FuncFormatter # ---------------------------------------------------------------------------- # ASSUMPTIONS # ---------------------------------------------------------------------------- REAL_RETURN = 0.07 SWR = 0.04 # 4% rule -> FIRE number = 25x annual spending START_AGE = 25 END_AGE = 65 START_PORTFOLIO = 48_000 # Roth IRA + Schwab Stocks today PIPELINE = {25: 7_000, 26: 7_000, 27: 7_000, 28: 3_900} # 529->Roth STIPEND = 34_000 # grad years (age 25-26) START_SALARY = 130_000 # industry, starting age 27 TAX_GRAD = 0.08 # effective all-in tax rate, grad years TAX_INDUSTRY = 0.30 # effective all-in tax rate, industry (fed+PA+FICA+local) SPENDING_FLOOR = 50_000 # Dane's rule: never live on less than $50k/yr (today's $) def gross_income(age): """Real (today's $) gross income by age: stipend -> industry w/ tapering raises.""" if age <= 26: return STIPEND income = START_SALARY for a in range(27, age): # apply raises year over year up to `age` if a < 40: income *= 1.03 # 3% real ascent, early career elif a < 50: income *= 1.01 # 1% real, mid career else: income *= 1.005 # ~flat, late career return income def tax_rate(age): return TAX_GRAD if age <= 26 else TAX_INDUSTRY # ---------------------------------------------------------------------------- # SAVINGS STRATEGIES — each returns SAVINGS DOLLARS given (age, gross, net). # The $50k spending floor is enforced: never save so much that you live on <$50k. # ---------------------------------------------------------------------------- def hybrid_floor(age, gross, net): """Front-load HARD ages 27-32 (live at the $50k floor, bank everything else), then deliberately loosen to ~20% of gross — the Corvette-and-hobbies years.""" if age <= 26: return 0.0 if age <= 32: # PHASE 1: live at floor, save the rest return max(0.0, net - SPENDING_FLOOR) target = gross * 0.20 # PHASE 2: loosen to 20% of gross return min(target, max(0.0, net - SPENDING_FLOOR)) def steady_aggressive(age, gross, net): if age <= 26: return 0.0 return min(gross * 0.30, max(0.0, net - SPENDING_FLOOR)) def balanced(age, gross, net): if age <= 26: return 0.0 return min(gross * 0.18, max(0.0, net - SPENDING_FLOOR)) STRATEGIES = { "Hybrid: hard 27-32 @ $50k floor, then loosen to 20%": (hybrid_floor, "#d93025"), "Steady aggressive (30%)": (steady_aggressive, "#188038"), "Balanced (18%)": (balanced, "#1a73e8"), } # ---------------------------------------------------------------------------- # SIMULATE # ---------------------------------------------------------------------------- def simulate(save_fn): ages, portfolio, spending, fire_num = [], [], [], [] bal = START_PORTFOLIO fire_age = None for age in range(START_AGE, END_AGE + 1): gross = gross_income(age) net = gross * (1 - tax_rate(age)) own_savings = save_fn(age, gross, net) spend = net - own_savings # what you live on fnum = 25 * spend # FIRE target for THIS lifestyle ages.append(age); portfolio.append(bal) spending.append(spend); fire_num.append(fnum) if fire_age is None and bal >= fnum and age >= 27: fire_age = age contrib = own_savings + PIPELINE.get(age, 0) bal = bal * (1 + REAL_RETURN) + contrib return dict(ages=ages, portfolio=portfolio, spending=spending, fire_num=fire_num, fire_age=fire_age) results = {label: simulate(fn) for label, (fn, _) in STRATEGIES.items()} # ---------------------------------------------------------------------------- # PLOT (2 panels: wealth+FIRE, then lifestyle) # ---------------------------------------------------------------------------- fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 10.5), height_ratios=[1.6, 1]) money_fmt = FuncFormatter(lambda v, _: f"${v/1e6:.1f}M" if v >= 1e6 else f"${v/1e3:.0f}k") # --- Panel 1: portfolio vs FIRE number --- for label, (fn, color) in STRATEGIES.items(): r = results[label] ax1.plot(r["ages"], r["portfolio"], color=color, linewidth=2.6, label=label) # FIRE number (dashed, same color, thin) ax1.plot(r["ages"], r["fire_num"], color=color, linewidth=1.1, linestyle=":", alpha=0.7) # mark FIRE achievement fa = r["fire_age"] if fa: idx = r["ages"].index(fa) ax1.scatter([fa], [r["portfolio"][idx]], color=color, s=90, zorder=5, edgecolor="white", linewidth=1.5) ax1.annotate(f"FI at {fa}", xy=(fa, r["portfolio"][idx]), xytext=(fa - 1.5, r["portfolio"][idx] + 0.35e6), fontsize=9.5, fontweight="bold", color=color) ax1.axvline(27, color="#5f6368", linestyle="--", alpha=0.5, linewidth=1.2) ax1.annotate("PhD done / industry income (~2028)", xy=(27, 0), xytext=(27.3, 5.0e6), fontsize=9, color="#5f6368") # lean-FI floor: $50k lifestyle => $1.25M ax1.axhline(25 * SPENDING_FLOOR, color="#f9ab00", linestyle="-.", alpha=0.8, linewidth=1.4) ax1.annotate(f"Lean-FI floor: 25 x $50k = ${25*SPENDING_FLOOR/1e6:.2f}M", xy=(START_AGE, 25*SPENDING_FLOOR), xytext=(START_AGE+0.3, 25*SPENDING_FLOOR+0.18e6), fontsize=9, color="#b06000", fontweight="bold") ax1.set_title("FIRE trajectories — solid = portfolio, dotted = FIRE number (25x spend)", fontsize=13, fontweight="bold") ax1.set_ylabel("Today's $") ax1.yaxis.set_major_formatter(money_fmt) ax1.set_xlim(START_AGE, END_AGE) ax1.set_ylim(bottom=0) ax1.grid(True, alpha=0.25) ax1.legend(loc="upper left", fontsize=9.5) # --- Panel 2: lifestyle (annual spending) --- for label, (fn, color) in STRATEGIES.items(): r = results[label] ax2.plot(r["ages"], r["spending"], color=color, linewidth=2.4, label=label) ax2.set_title("What you actually live on each year (annual spending, today's $)", fontsize=13, fontweight="bold") ax2.set_xlabel("Age") ax2.set_ylabel("Annual spending") ax2.yaxis.set_major_formatter(money_fmt) ax2.set_xlim(START_AGE, END_AGE) ax2.set_ylim(bottom=0) ax2.grid(True, alpha=0.25) ax2.axhline(STIPEND*(1-TAX_GRAD), color="#9aa0a6", linestyle="--", alpha=0.6) ax2.annotate("~current grad take-home", xy=(50, STIPEND*(1-TAX_GRAD)), xytext=(50, STIPEND*(1-TAX_GRAD)+2500), fontsize=8.5, color="#9aa0a6") # the $50k floor — the line Dane won't cross ax2.axhline(SPENDING_FLOOR, color="#f9ab00", linestyle="-.", alpha=0.9, linewidth=1.6) ax2.annotate("$50k floor — won't live below this", xy=(40, SPENDING_FLOOR), xytext=(40, SPENDING_FLOOR+3000), fontsize=9, color="#b06000", fontweight="bold") ax2.legend(loc="upper left", fontsize=9.5) fig.tight_layout() out = "/Users/danesabo/Documents/Finances/fire_projection.png" fig.savefig(out, dpi=140, bbox_inches="tight") # ---------------------------------------------------------------------------- # TEXT SUMMARY # ---------------------------------------------------------------------------- print("=" * 72) print("FIRE PROJECTION (today's dollars, 7% real, rising-income career arc)") print("=" * 72) print(f"Income arc: ${STIPEND:,} stipend -> ${START_SALARY:,} at 27, " f"3%/1%/0.5% real raises\n") LEAN_FI = 25 * SPENDING_FLOOR # $1.25M -> permanently able to fall back to $50k for label in STRATEGIES: r = results[label] fa = r["fire_age"] # spending + portfolio at a few ages def at(age): return r["portfolio"][r["ages"].index(age)] def sp(age): return r["spending"][r["ages"].index(age)] # age at which portfolio first crosses the lean-FI safety net ($1.25M) lean_age = next((a for a, p in zip(r["ages"], r["portfolio"]) if p >= LEAN_FI and a >= 27), None) print(label) print(f" $50k safety net funded (cross ${LEAN_FI/1e6:.2f}M): " f"{'age '+str(lean_age) if lean_age else 'not by 65'}") print(f" Full FIRE on actual lifestyle: {'age '+str(fa) if fa else 'not by 65'}") print(f" Lifestyle (annual spend) at 30/40/50: " f"${sp(30)/1e3:.0f}k / ${sp(40)/1e3:.0f}k / ${sp(50)/1e3:.0f}k") print(f" Portfolio at 50 / 65: ${at(50)/1e6:.2f}M / ${at(65)/1e6:.2f}M") print() print(f"Chart saved: {out}")