[05] When to Pull the Trigger on FIRE — Monte Carlo Says You're Already Free
This is Part 5 of a 6-part series: Building Investment Systems with Python
The Problem with Target Numbers
"You need 25x your annual expenses." That's the standard FIRE rule.
For ¥9.6M annual expenses, that's ¥240M. Most people see that number and think: "I'll never get there."
But the 25x rule assumes a fixed 4% withdrawal rate, zero income, zero adaptability, and a single deterministic path. Real life isn't deterministic. Real life is stochastic.
What if instead of a single number, you had a probability? "Given my current assets, income, and dividend trajectory, there's a 94% chance I can sustain my lifestyle for 40 years."
That changes the conversation entirely.
The Monte Carlo Engine
# monte_carlo_fire.py
import numpy as np
from dataclasses import dataclass
from typing import List, Tuple
@dataclass
class FIREParams:
initial_portfolio: float = 125_000_000
initial_dividends: float = 5_000_000 # annual
annual_expenses: float = 9_600_000
side_income: float = 12_000_000 # annual (optional work)
side_income_decline_rate: float = 0.05 # reduce work 5%/year
dividend_growth_rate: float = 0.06
dividend_growth_std: float = 0.03 # uncertainty in growth
market_return: float = 0.07 # long-term nominal
market_volatility: float = 0.20
inflation_rate: float = 0.02
inflation_std: float = 0.01
loan_balance: float = 50_000_000
loan_rate: float = 0.02
margin_liq_ratio: float = 0.85
tax_rate: float = 0.20315 # Japanese dividend tax
years: int = 40
simulations: int = 10_000
def run_simulation(params: FIREParams) -> Tuple[np.ndarray, dict]:
"""
Run Monte Carlo simulation of FIRE sustainability.
Each path simulates:
- Stochastic market returns (geometric Brownian motion)
- Stochastic dividend growth (normal distribution)
- Stochastic inflation
- Declining side income (optional work reduces over time)
- Margin loan dynamics (forced liquidation if triggered)
- Tax on dividends
"""
np.random.seed(42)
results = np.zeros(params.simulations) # 1 = survived, 0 = failed
failure_years = []
wealth_paths = np.zeros((params.simulations, params.years + 1))
income_paths = np.zeros((params.simulations, params.years + 1))
for sim in range(params.simulations):
portfolio = params.initial_portfolio
dividends = params.initial_dividends
expenses = params.annual_expenses
side_income = params.side_income
loan = params.loan_balance
survived = True
for year in range(params.years + 1):
wealth_paths[sim, year] = portfolio - loan
income_paths[sim, year] = dividends
if year == params.years:
break
# Market return (log-normal)
market_return = np.random.normal(
params.market_return - 0.5 * params.market_volatility**2,
params.market_volatility
)
portfolio *= np.exp(market_return)
# Check margin liquidation
if loan > 0 and portfolio > 0:
margin_ratio = loan / portfolio
if margin_ratio > params.margin_liq_ratio:
# Forced liquidation — lose everything
portfolio = max(0, portfolio - loan)
loan = 0
# Dividend growth (stochastic)
div_growth = np.random.normal(
params.dividend_growth_rate,
params.dividend_growth_std
)
dividends *= (1 + max(div_growth, -0.30)) # floor: -30% cut
# After-tax dividend income
net_dividends = dividends * (1 - params.tax_rate)
# Side income (declining)
side_income *= (1 - params.side_income_decline_rate)
# Inflation
inflation = np.random.normal(params.inflation_rate, params.inflation_std)
expenses *= (1 + max(inflation, 0))
# Cash flow
total_income = net_dividends + side_income
net_cashflow = total_income - expenses - (loan * params.loan_rate)
# If negative cashflow, draw from portfolio
if net_cashflow < 0:
portfolio += net_cashflow # net_cashflow is negative
# If positive, reinvest
else:
portfolio += net_cashflow
# Check survival
if portfolio <= 0:
survived = False
failure_years.append(year)
break
results[sim] = 1 if survived else 0
# Statistics
survival_rate = results.mean()
stats = {
'survival_rate': survival_rate,
'median_final_wealth': np.median(wealth_paths[:, -1]),
'p10_final_wealth': np.percentile(wealth_paths[:, -1], 10),
'p90_final_wealth': np.percentile(wealth_paths[:, -1], 90),
'mean_failure_year': np.mean(failure_years) if failure_years else None,
'wealth_paths': wealth_paths,
'income_paths': income_paths,
}
return results, stats
def print_report(params: FIREParams, results: np.ndarray, stats: dict):
print("╔══════════════════════════════════════════════════════╗")
print("║ MONTE CARLO FIRE ANALYSIS ║")
print(f"║ {params.simulations:,} simulations × {params.years} years ║")
print("╠══════════════════════════════════════════════════════╣")
print(f"║ Portfolio: ¥{params.initial_portfolio:>14,.0f} ║")
print(f"║ Annual Dividends: ¥{params.initial_dividends:>14,.0f} ║")
print(f"║ Annual Expenses: ¥{params.annual_expenses:>14,.0f} ║")
print(f"║ Side Income: ¥{params.side_income:>14,.0f} (declining) ║")
print(f"║ Loan Balance: ¥{params.loan_balance:>14,.0f} ║")
print("╠══════════════════════════════════════════════════════╣")
sr = stats['survival_rate']
emoji = "🔥" if sr > 0.90 else "✅" if sr > 0.80 else "⚠️" if sr > 0.60 else "❌"
print(f"║ {emoji} SURVIVAL RATE: {sr:>8.1%} ║")
print("╠══════════════════════════════════════════════════════╣")
print(f"║ Final Wealth (median): ¥{stats['median_final_wealth']:>14,.0f} ║")
print(f"║ Final Wealth (P10): ¥{stats['p10_final_wealth']:>14,.0f} ║")
print(f"║ Final Wealth (P90): ¥{stats['p90_final_wealth']:>14,.0f} ║")
if stats['mean_failure_year']:
print(f"║ Avg Failure Year: {stats['mean_failure_year']:>8.1f} ║")
print("╚══════════════════════════════════════════════════════╝")
print()
if sr >= 0.90:
print("You are FI. The cage door is open.")
elif sr >= 0.80:
print("Very close. 1-2 more years of accumulation closes the gap.")
elif sr >= 0.60:
print("Getting there. Side income is still important.")
else:
print("Not yet FI. Focus on growing passive income.")
def sensitivity_analysis(base_params: FIREParams):
"""What variables matter most for survival?"""
print("\nSENSITIVITY ANALYSIS")
print("─" * 60)
print(f"{'Variable':>30} {'Change':>10} {'Survival':>10}")
print("─" * 60)
# Base case
_, base_stats = run_simulation(base_params)
print(f"{'Base case':>30} {'':>10} {base_stats['survival_rate']:>9.1%}")
# Test each variable
tests = [
("Expenses +20%", {'annual_expenses': base_params.annual_expenses * 1.2}),
("Expenses -20%", {'annual_expenses': base_params.annual_expenses * 0.8}),
("No side income", {'side_income': 0}),
("Side income 2x", {'side_income': base_params.side_income * 2}),
("Div growth +2%", {'dividend_growth_rate': base_params.dividend_growth_rate + 0.02}),
("Div growth -2%", {'dividend_growth_rate': base_params.dividend_growth_rate - 0.02}),
("No loan", {'loan_balance': 0}),
("Higher volatility", {'market_volatility': 0.30}),
]
for label, overrides in tests:
from dataclasses import replace
test_params = replace(base_params, **overrides)
_, test_stats = run_simulation(test_params)
delta = test_stats['survival_rate'] - base_stats['survival_rate']
arrow = "↑" if delta > 0 else "↓" if delta < 0 else "→"
print(f"{label:>30} {arrow} {delta:>+8.1%} {test_stats['survival_rate']:>9.1%}")
if __name__ == "__main__":
params = FIREParams()
results, stats = run_simulation(params)
print_report(params, results, stats)
sensitivity_analysis(params)
Reading the Results
The survival rate is your FIRE confidence level:
| Survival Rate | Interpretation |
|---|---|
| >95% | Absolutely FI. Work is purely optional. |
| 90-95% | FI with high confidence. Mild adaptability needed in worst cases. |
| 80-90% | Probably FI. Keep some income optionality. |
| 60-80% | Side-FIRE. Work covers the gap in bad scenarios. |
| <60% | Not yet FI. Keep building. |
The sensitivity analysis tells you what lever moves the needle most. Often, it's not "save more" — it's "spend less" or "maintain side income for 3 more years."
The Value of Optionality
Here's what the Monte Carlo reveals that deterministic models miss:
Working "by choice" isn't just lifestyle — it's insurance.
Even ¥300K/month of optional work (¥3.6M/year) can push survival rate from 82% to 95%. That's not because you need the money — it's because in the 18% of scenarios where markets crash early, having income prevents you from selling at the bottom.
# The value of optionality
no_work = FIREParams(side_income=0)
some_work = FIREParams(side_income=3_600_000)
full_work = FIREParams(side_income=12_000_000)
# The jump from 0 → ¥3.6M has far more impact
# than ¥3.6M → ¥12M.
# The first dollars of optional income are the most valuable.
This is why "Side-FIRE" isn't a compromise — it's the mathematically optimal strategy for most people. Full FIRE requires 95%+ survival without income. Side-FIRE requires a few hours of enjoyable work to achieve the same confidence.
What We Built
- A full Monte Carlo engine with stochastic returns, inflation, and dividend growth
- 10,000-path simulation with survival rate computation
- Sensitivity analysis showing which variables matter most
- Quantitative proof that optionality (side income) is the cheapest insurance
Next week: [06] Portfolio Defense Dashboard — "One screen that answers 'am I safe?' every morning."
Series: Building Investment Systems with Python — Engineering financial independence with code.
Top comments (0)