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>
200 lines
9.0 KiB
Python
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}")
|