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>
145 lines
6.4 KiB
Python
145 lines
6.4 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Retirement trajectory projection for Dane.
|
|
|
|
Models portfolio growth from age 25 -> 65 under three post-PhD savings
|
|
scenarios, plus shows how negligible the one-time summer 401k contribution is.
|
|
|
|
All figures are in TODAY'S DOLLARS (real returns), so they're directly
|
|
comparable to current purchasing power. Edit the ASSUMPTIONS block and re-run.
|
|
|
|
/tmp/retire-venv/bin/python retirement_projection.py
|
|
"""
|
|
|
|
import matplotlib
|
|
matplotlib.use("Agg") # no display needed; save to file
|
|
import matplotlib.pyplot as plt
|
|
from matplotlib.ticker import FuncFormatter
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# ASSUMPTIONS (edit these and re-run)
|
|
# ----------------------------------------------------------------------------
|
|
REAL_RETURN = 0.07 # 7% real (inflation-adjusted) annual return
|
|
SWR = 0.04 # 4% safe withdrawal rate
|
|
START_AGE = 25
|
|
END_AGE = 65
|
|
START_PORTFOLIO = 48_000 # Roth IRA ($16k) + Schwab Stocks ($32k) today
|
|
|
|
# 529 -> Roth IRA pipeline (grandparent-funded). $24.9k total, $7k/yr cap.
|
|
PIPELINE = {25: 7_000, 26: 7_000, 27: 7_000, 28: 3_900} # sums to $24,900
|
|
|
|
# Post-PhD own savings (401k + brokerage). PhD done end of 2027 => income 2028.
|
|
POST_PHD_START_AGE = 27 # 2028 = age 27
|
|
SCENARIOS = {
|
|
"Floor — no post-PhD saving": 0,
|
|
"Moderate — $20k/yr post-PhD": 20_000,
|
|
"Aggressive — $35k/yr post-PhD": 35_000,
|
|
}
|
|
|
|
# The contested decision: one summer of 10% Roth 401k = ~$2,240 at age 25.
|
|
SUMMER_401K = 2_240
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# MODEL
|
|
# ----------------------------------------------------------------------------
|
|
def project(post_phd_rate, summer_401k=0):
|
|
"""Year-by-year: grow existing balance, then add year-end contributions.
|
|
Returns (ages, balances)."""
|
|
ages, balances = [], []
|
|
bal = START_PORTFOLIO + summer_401k # summer contribution lands at age 25
|
|
for age in range(START_AGE, END_AGE + 1):
|
|
ages.append(age)
|
|
balances.append(bal)
|
|
# contribution for THIS year (added at year-end, after growth)
|
|
contrib = PIPELINE.get(age, 0)
|
|
if age >= POST_PHD_START_AGE:
|
|
contrib += post_phd_rate
|
|
bal = bal * (1 + REAL_RETURN) + contrib
|
|
return ages, balances
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# PLOT
|
|
# ----------------------------------------------------------------------------
|
|
fig, ax = plt.subplots(figsize=(12, 7.5))
|
|
colors = {"Floor — no post-PhD saving": "#9aa0a6",
|
|
"Moderate — $20k/yr post-PhD": "#1a73e8",
|
|
"Aggressive — $35k/yr post-PhD": "#188038"}
|
|
|
|
finals = {}
|
|
for label, rate in SCENARIOS.items():
|
|
ages, bals = project(rate)
|
|
finals[label] = bals[-1]
|
|
ax.plot(ages, bals, label=label, linewidth=2.6, color=colors[label])
|
|
# final value annotation
|
|
final = bals[-1]
|
|
income = final * SWR
|
|
ax.annotate(f" ${final/1e6:.2f}M\n (${income/1e3:.0f}k/yr @ 4%)",
|
|
xy=(65, final), xytext=(65.4, final),
|
|
va="center", fontsize=10, fontweight="bold",
|
|
color=colors[label])
|
|
|
|
# The summer 401k question, framed HONESTLY.
|
|
# $2,240 grows to the same gross amount in ANY account. The real question is
|
|
# Roth-401k vs taxable-brokerage (Dane's actual alternative — he'd save it, not
|
|
# spend it). The only delta is the tax treatment of the growth.
|
|
LTCG_RATE = 0.15
|
|
summer_gross = SUMMER_401K * (1 + REAL_RETURN) ** (END_AGE - START_AGE) # grows either way
|
|
summer_gain = summer_gross - SUMMER_401K
|
|
summer_taxable_net = summer_gross - summer_gain * LTCG_RATE # taxable: pay LTCG on gain
|
|
roth_advantage = summer_gross - summer_taxable_net # Roth keeps the tax
|
|
|
|
# vertical marker: PhD done / income starts
|
|
ax.axvline(POST_PHD_START_AGE, color="#d93025", linestyle="--", alpha=0.55, linewidth=1.4)
|
|
ax.annotate("PhD done /\nincome starts\n(~2028)", xy=(POST_PHD_START_AGE, ax.get_ylim()[1]*0.0),
|
|
xytext=(POST_PHD_START_AGE + 0.3, finals["Aggressive — $35k/yr post-PhD"]*0.62),
|
|
fontsize=9, color="#d93025")
|
|
|
|
# annotation box: the summer 401k decision, framed honestly
|
|
txt = (f"The contested decision (summer 10% Roth 401k, ~${SUMMER_401K:,}):\n"
|
|
f"• Grows to ~${summer_gross/1e3:.0f}k by 65 in ANY account\n"
|
|
f"• vs taxable brokerage (your real alt.), the Roth tax\n"
|
|
f" benefit is only ~${roth_advantage/1e3:.1f}k — the rest happens\n"
|
|
f" whether it's Roth or not.\n"
|
|
f"• On a ${finals['Aggressive — $35k/yr post-PhD']/1e6:.1f}M trajectory: a rounding error.")
|
|
ax.text(0.03, 0.97, txt, transform=ax.transAxes, fontsize=9.5,
|
|
va="top", ha="left",
|
|
bbox=dict(boxstyle="round,pad=0.5", facecolor="#fef7e0", edgecolor="#f9ab00"))
|
|
|
|
# formatting
|
|
ax.set_title("Dane's Retirement Trajectory — today's dollars, 7% real return",
|
|
fontsize=14, fontweight="bold", pad=14)
|
|
ax.set_xlabel("Age", fontsize=11)
|
|
ax.set_ylabel("Portfolio value (today's $)", fontsize=11)
|
|
ax.yaxis.set_major_formatter(FuncFormatter(lambda v, _: f"${v/1e6:.1f}M"))
|
|
ax.set_xlim(START_AGE, 69.5)
|
|
ax.set_ylim(bottom=0)
|
|
ax.grid(True, alpha=0.25)
|
|
ax.legend(loc="center left", bbox_to_anchor=(0.03, 0.55), fontsize=10, framealpha=0.9)
|
|
|
|
fig.tight_layout()
|
|
out = "/Users/danesabo/Documents/Finances/retirement_projection.png"
|
|
fig.savefig(out, dpi=140, bbox_inches="tight")
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# TEXT SUMMARY
|
|
# ----------------------------------------------------------------------------
|
|
print("=" * 64)
|
|
print("RETIREMENT PROJECTION (today's dollars, 7% real return)")
|
|
print("=" * 64)
|
|
print(f"Start: ${START_PORTFOLIO:,} at age {START_AGE}")
|
|
print(f"529->Roth pipeline: ${sum(PIPELINE.values()):,} over ages "
|
|
f"{min(PIPELINE)}-{max(PIPELINE)}")
|
|
print(f"Post-PhD saving starts at age {POST_PHD_START_AGE}\n")
|
|
print(f"{'Scenario':<32}{'Age 65':>12}{'Income @4%':>14}")
|
|
print("-" * 58)
|
|
for label, final in finals.items():
|
|
print(f"{label:<32}{'$'+format(final/1e6, '.2f')+'M':>12}"
|
|
f"{'$'+format(final*SWR/1e3, '.0f')+'k/yr':>14}")
|
|
print("-" * 58)
|
|
print(f"\nSummer 10% Roth 401k (~${SUMMER_401K:,} one-time at age 25):")
|
|
print(f" grows to ~${summer_gross:,.0f} by age 65 in ANY account")
|
|
print(f" Roth-vs-taxable tax benefit only: ~${roth_advantage:,.0f}")
|
|
print(f" (= {roth_advantage/finals['Aggressive — $35k/yr post-PhD']*100:.3f}% "
|
|
f"of the aggressive total — a rounding error)")
|
|
print(f"\nChart saved: {out}")
|