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:
parent
a37d8f4d2c
commit
d7c6521cf0
199
fire_projection.py
Normal file
199
fire_projection.py
Normal 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}")
|
||||
@ -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
144
retirement_projection.py
Normal 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}")
|
||||
Loading…
x
Reference in New Issue
Block a user