DEV Community

soy
soy

Posted on

[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)
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)