diff --git a/fire_projection.py b/fire_projection.py new file mode 100644 index 0000000..0cb5db2 --- /dev/null +++ b/fire_projection.py @@ -0,0 +1,199 @@ +#!/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}") diff --git a/merchant_map.json b/merchant_map.json index 0250d42..1756bd3 100644 --- a/merchant_map.json +++ b/merchant_map.json @@ -5,6 +5,59 @@ "daily_cash_adjustment": "'DAILY CASH ADJUSTMENT' => Apple Card Daily Cash; the ADJUSTMENT is NEGATIVE cashback income (clawback on a return). Sign follows amount; revenue acct 'Apple Card Cashback'." }, "rules": [ + { + "match": "MOBILE DEPOSIT xxxxx5053", + "account_name": "Emerson", + "category": "Wages", + "type": "deposit" + }, + { + "match": "APPLE GS SAVINGS TRANSFER", + "account_name": "Apple Cashback", + "type": "deposit" + }, + { + "match": "HOBBY-LOBBY", + "account_name": "Hobby Lobby", + "category": "Recreation: Painting", + "type": "withdrawal" + }, + { + "match": "PAMPURRED PAWS", + "account_name": "Pampurred Paws", + "category": "Pets", + "type": "withdrawal" + }, + { + "match": "PITTSBURGH ZOO", + "account_name": "Pittsburgh Zoo", + "type": "withdrawal", + "review": true + }, + { + "match": "BOJANGLES", + "account_name": "Bojangles", + "category": "Restaurants", + "type": "withdrawal" + }, + { + "match": "BP#9644485HUNTERSVI", + "account_name": "BP", + "category": "Auto: Fuel", + "type": "withdrawal" + }, + { + "match": "CHARLOTTE MOTOR SPEED", + "account_name": "Charlotte Motor Speedway", + "category": "Recreation: Racing", + "type": "withdrawal" + }, + { + "match": "EXXON ONE STOP #513", + "account_name": "Exxon", + "category": "Auto: Fuel", + "type": "withdrawal" + }, { "match": "UTRECHT ART 80044718921930 EAST", "account_name": "Blick's Art Supply", @@ -421,9 +474,9 @@ }, { "match": "RED ROBIN NO", - "account_name": "Red Robin No", + "account_name": "Red Robin", "type": "withdrawal", - "_auto_tail": true + "category": "Restaurants" }, { "match": "ORDERENGINE", diff --git a/retirement_projection.py b/retirement_projection.py new file mode 100644 index 0000000..07afc88 --- /dev/null +++ b/retirement_projection.py @@ -0,0 +1,144 @@ +#!/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}")