[05] When to Pull the Trigger on FIRE — Monte Carlo Says You're Already Free
Tech

[05] When to Pull the Trigger on FIRE — Monte Carlo Says You're Already Free

[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.

Read full story →

Comments

Loading comments…

Related