finances/fire_projection.py
Dane Sabo d7c6521cf0 merchant_map: +9 rules from May 2026 import learning loop
New merchants: Hobby Lobby, Pampurred Paws, Bojangles, Pittsburgh Zoo;
consolidations to existing BP/Exxon/Charlotte Motor Speedway; income
mappings for Emerson (paycheck) and Apple Cashback. Fix stale Red Robin
auto-tail rule (was spawning "Red Robin No" instead of consolidating).

Add retirement_projection.py + fire_projection.py: today's-dollars
compounding + FIRE/Coast-FIRE models with $50k spending floor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:18:17 -04:00

200 lines
9.0 KiB
Python

#!/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}")