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>
This commit is contained in:
Dane Sabo 2026-06-22 09:18:17 -04:00
parent a37d8f4d2c
commit d7c6521cf0
3 changed files with 398 additions and 2 deletions

199
fire_projection.py Normal file
View File

@ -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}")

View File

@ -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",

144
retirement_projection.py Normal file
View File

@ -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}")