Learning Objectives

What You'll Learn Today

Section 1

Monte Carlo Simulation Fundamentals

From the casino to Wall Street — understanding stochastic modeling

💭
Think About It

You built a DCF model that says a stock is worth ₹2,500. But what if your growth rate assumption is wrong? What if WACC is higher? How do you quantify the RANGE of possible outcomes?

Monte Carlo simulation replaces single assumptions with probability distributions — and runs the model 10,000 times to show you the full picture

📖 What is Monte Carlo Simulation?

Named after the famous Monte Carlo Casino in Monaco — where fortunes depend on the roll of a dice — Monte Carlo simulation is a powerful computational technique that uses repeated random sampling to understand how uncertainty in your assumptions translates into uncertainty in your results.

🌦️Everyday Analogy — Think of Weather Forecasting

Imagine you're planning an outdoor event next Saturday. A deterministic forecast says: "It will be 32°C and sunny." — a single prediction. But a Monte Carlo-style forecast says: "Based on 1,000 weather simulations, there is a 75% chance of sunshine (28–35°C), a 15% chance of clouds, and a 10% chance of rain."

Which forecast helps you make a better decision? The second one — because it tells you the range of possibilities and the probability of each. That's exactly what Monte Carlo does for your financial models.

In traditional financial modeling (like the DCF models we built in Sessions 19–20), you plug in one fixed number for each assumption — revenue grows at 12%, margins are 26%, WACC is 11.5%, terminal growth is 3%, and so on. The model gives you one answer: "The stock is worth ₹2,500."

But in reality, you don't know the exact growth rate. It could be 8% in a bad year or 16% in a great year. Monte Carlo simulation acknowledges this uncertainty by replacing each fixed assumption with a probability distribution — a range of possible values with different likelihoods — and then runs the model thousands of times, each time picking a different random combination of inputs. The result is not one number, but a full picture of all possible outcomes.

Traditional vs. Monte Carlo: Side-by-Side

Aspect📊 Traditional (Deterministic)🎲 Monte Carlo (Stochastic)
How inputs work You pick one fixed number for each assumption
e.g., "Revenue grows exactly 12%"
You define a range with probabilities for each assumption
e.g., "Revenue grows 8–16%, most likely 12%" (triangular distribution)
What you get as output A single answer
"Your DCF model says the stock is worth ₹2,500"
A distribution of 10,000+ possible answers
"The stock is worth ₹1,800–₹3,200 with 90% confidence; median ₹2,500"
Number of scenarios 3 at most (Bull / Base / Bear)
You manually define each one
10,000+ scenarios, automatically generated
Computer randomly samples from your distributions each time
How risk is expressed Vague: "There is some downside risk" Precise: "There is a 32% probability of losing money"
Decision quality Gut-feel: "I think it's a buy" Data-driven: "82% probability of positive return → buy"
🧮A Simple Concrete Example

Traditional DCF: You build a full DCF model (with revenue, margins, capex, shares, net debt, etc.) and plug in fixed assumptions: Growth = 10%, WACC = 11%, Terminal Growth = 3%. The model spits out one answer → Stock = ₹2,500.

Monte Carlo DCF: You use the same DCF model, but instead of fixing Growth at 10%, you say "Growth could range from 6% to 14%, most likely 10%" (a Triangular distribution). You do the same for WACC and Terminal Growth. Then the computer runs your DCF 10,000 times, each time picking a different random combination. Result → not one number, but a range: ₹1,800 to ₹3,400, with a median of ₹2,480 and a 68% probability the value is above ₹2,200.

The second approach is far more useful for decision-making — it tells you both the most likely value and the range of uncertainty around it.

🔄 The Monte Carlo Process — 5 Steps Explained

Every Monte Carlo simulation — whether you're valuing a stock, evaluating a factory investment, or assessing portfolio risk — follows the same 5-step process:

Step 1: DEFINE the Model

Start with the same financial model you already know — DCF, NPV, IRR, or any formula that takes inputs and produces an output. This is your deterministic model (the one you built in earlier sessions). Example: The DCF model that calculates intrinsic value from revenue growth, margins, WACC, and terminal growth.

Step 2: ASSIGN Probability Distributions to Uncertain Inputs

Instead of plugging in a single number for each uncertain input, you assign a probability distribution that describes the range of plausible values and how likely each value is. You keep known inputs (like shares outstanding or tax rate) as fixed. Example: Revenue growth → Triangular(6%, 10%, 14%), meaning "most likely 10%, but could be as low as 6% or as high as 14%."

Step 3: SIMULATE — Run the Model N Times (Typically 10,000+)

The computer randomly picks one value from each distribution, plugs them into your model, and records the output. Then it does this again... and again... 10,000 times. Each run uses a different random combination of inputs, so each run produces a slightly different answer. Example: Run 1 might use growth=9.3%, WACC=10.8%. Run 2 might use growth=11.7%, WACC=11.4%. Each gives a different intrinsic value.

Step 4: ANALYZE the Distribution of Outputs

After 10,000 runs, you have 10,000 results. Plot them as a histogram to see the distribution. Calculate statistics: mean, median, standard deviation, percentiles (P5 = pessimistic, P50 = median, P95 = optimistic). The 90% confidence interval is [P5, P95]. Example: 10,000 intrinsic values → median ₹2,170, P5 = ₹1,594, P95 = ₹3,153.

Step 5: DECIDE Based on Probabilities, Not Single Points

Compare your simulation results against a target (e.g., current market price) and make a probability-based decision. Example: "Only 1.1% of simulations show intrinsic value above ₹3,800 (market price) — the stock appears significantly overvalued at current prices. A conservative investor would want >70% probability before buying."

Summary — The Monte Carlo Process
1. DEFINE → Build your financial model (DCF, NPV, IRR, etc.)
2. ASSIGN → Replace fixed assumptions with probability distributions
3. SIMULATE → Run the model 10,000+ times with random inputs
4. ANALYZE → Study the distribution: mean, median, percentiles, confidence intervals
5. DECIDE → Make decisions based on probabilities, not gut feelings

📐 Common Probability Distributions for Finance

DistributionShapeUse CasePython (NumPy)
Normal (Gaussian)Bell curve, symmetricWACC, margins, returnsnp.random.normal(mean, std, N)
TriangularTriangle: min–mode–maxGrowth rates, capex estimatesnp.random.triangular(left, mode, right, N)
UniformFlat: all values equally likelyWhen you only know the rangenp.random.uniform(low, high, N)
LognormalRight-skewed, always positiveStock prices, revenue (can't be negative)np.random.lognormal(mean, sigma, N)
PERTSmooth bell-like triangularProject management estimatesCustom (see template)
💡Rule of Thumb

Use Triangular when you have a best/worst/most likely estimate from management. Use Normal when you have historical mean and standard deviation. Use Lognormal for variables that cannot be negative (stock prices, revenue).

✏️ Worked Example 1: Monte Carlo DCF for TCS

🎯The Big Question

TCS (Tata Consultancy Services) is trading at ₹3,800 per share in the market today. As an analyst, you've built a DCF model — but your growth rate, margin, and WACC assumptions are uncertain. Instead of guessing one number for each, you'll run 10,000 different versions of the DCF model with randomly sampled inputs and ask: "What is the probability that TCS is actually worth more than ₹3,800?"

Step 1: Setting Up the Assumptions

We identify which inputs are uncertain (need a distribution) and which are known (stay fixed). For each uncertain input, we choose a distribution and explain why:

Input VariableDistributionParametersWhy This Distribution?
Revenue Growth (Yr 1–5)TriangularMin 6%, Mode 10%, Max 14%Management guidance suggests ~10% growth, but could range 6–14%. Triangular captures this "best guess with bounds."
EBITDA MarginNormalMean 26%, Std 1.5%TCS has historically maintained ~26% margins with small fluctuations. Normal distribution captures this stable-but-slightly-variable pattern.
WACCNormalMean 11%, Std 0.8%WACC depends on beta, risk-free rate, and market premium — all slightly uncertain. Normal distribution centers on our best estimate with small variation.
Terminal GrowthTriangularMin 4%, Mode 5.5%, Max 7%Long-term GDP growth + inflation estimate. Triangular because we have a reasonable range but the exact value is unknown.
Shares OutstandingFixed361 Cr sharesKnown from the latest annual report — no uncertainty.
Net DebtFixed₹8,000 CrKnown from the balance sheet — no uncertainty.

Step 2: What the Simulation Will Do

The code below will repeat this process 10,000 times:

Each Simulation Run (repeated 10,000×)

Randomly pick a revenue growth rate (e.g., 9.3%), an EBITDA margin (e.g., 25.8%), a WACC (e.g., 10.6%), and a terminal growth rate (e.g., 5.2%) — all from their respective distributions.
Use these random values to project 5 years of Free Cash Flows (FCFs).
Calculate the Terminal Value using Gordon Growth Model.
Discount everything back to today → Enterprise Value → subtract Net Debt → Equity Value ÷ Shares = Intrinsic Value per share for this run.
Store this result. Move to the next run with a fresh set of random inputs.

After 10,000 runs, we have 10,000 different intrinsic values — one for each random combination of inputs. We then analyze this distribution to answer our question.

Python — Monte Carlo DCF for TCS
import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)
N_SIMULATIONS = 10_000

# ========================================
# STEP 1: Define Distributions
# ========================================
# Revenue Growth (Triangular): min=6%, mode=10%, max=14%
rev_growth = np.random.triangular(0.06, 0.10, 0.14, N_SIMULATIONS)

# EBITDA Margin (Normal): mean=26%, std=1.5%
ebitda_margin = np.random.normal(0.26, 0.015, N_SIMULATIONS)
ebitda_margin = np.clip(ebitda_margin, 0.15, 0.35)  # Bound it

# WACC (Normal): mean=11%, std=0.8%
wacc = np.random.normal(0.11, 0.008, N_SIMULATIONS)
wacc = np.clip(wacc, 0.08, 0.15)

# Terminal Growth (Triangular): min=4%, mode=5.5%, max=7%
term_growth = np.random.triangular(0.04, 0.055, 0.07, N_SIMULATIONS)

# Fixed parameters
base_revenue = 240_000  # ₹ Cr (TCS FY24 revenue)
net_debt = 8_000        # ₹ Cr
shares = 361            # Cr shares
tax_rate = 0.2517
da_percent = 0.03       # D&A as % of revenue
capex_percent = 0.04    # Capex as % of revenue
nwc_percent = 0.02      # ΔNWC as % of revenue

# ========================================
# STEP 2: Simulate DCF 10,000 times
# ========================================
intrinsic_values = np.zeros(N_SIMULATIONS)

for i in range(N_SIMULATIONS):
    revenue = base_revenue
    fcff_list = []
    
    for year in range(1, 6):
        revenue *= (1 + rev_growth[i])
        ebitda = revenue * ebitda_margin[i]
        ebit = ebitda - (revenue * da_percent)
        tax = ebit * tax_rate
        nopat = ebit - tax
        capex = revenue * capex_percent
        dnwc = revenue * nwc_percent
        fcff = nopat + (revenue * da_percent) - capex - dnwc
        fcff_list.append(fcff)
    
    # Terminal Value
    terminal_fcff = fcff_list[-1] * (1 + term_growth[i])
    terminal_value = terminal_fcff / (wacc[i] - term_growth[i])
    
    # PV of FCFFs
    pv_fcff = sum(f / (1 + wacc[i])**t for t, f in enumerate(fcff_list, 1))
    pv_terminal = terminal_value / (1 + wacc[i])**5
    
    enterprise_value = pv_fcff + pv_terminal
    equity_value = enterprise_value - net_debt
    intrinsic_values[i] = equity_value / shares

# ========================================
# STEP 3: Analyze Results
# ========================================
current_price = 3800
prob_undervalued = (intrinsic_values > current_price).mean() * 100

print("=" * 55)
print("MONTE CARLO DCF RESULTS — TCS (10,000 simulations)")
print("=" * 55)
print(f"Mean Intrinsic Value:    ₹{intrinsic_values.mean():,.0f}")
print(f"Median Intrinsic Value:  ₹{np.median(intrinsic_values):,.0f}")
print(f"Std Deviation:           ₹{intrinsic_values.std():,.0f}")
print(f"")
print(f"5th Percentile (Pessimistic):  ₹{np.percentile(intrinsic_values, 5):,.0f}")
print(f"25th Percentile:               ₹{np.percentile(intrinsic_values, 25):,.0f}")
print(f"50th Percentile (Median):      ₹{np.percentile(intrinsic_values, 50):,.0f}")
print(f"75th Percentile:               ₹{np.percentile(intrinsic_values, 75):,.0f}")
print(f"95th Percentile (Optimistic):  ₹{np.percentile(intrinsic_values, 95):,.0f}")
print(f"")
print(f"Current Market Price:    ₹{current_price:,}")
print(f"Probability Undervalued: {prob_undervalued:.1f}%")
print(f"Probability Overvalued:  {100 - prob_undervalued:.1f}%")

# ========================================
# STEP 4: Visualize
# ========================================
fig, axes = plt.subplots(1, 2, figsize=(16, 6))

# Histogram
axes[0].hist(intrinsic_values, bins=50, color='#3B82F6', alpha=0.7, edgecolor='white')
axes[0].axvline(current_price, color='red', linewidth=2, linestyle='--', label=f'Market Price ₹{current_price:,}')
axes[0].axvline(np.median(intrinsic_values), color='green', linewidth=2, linestyle='-', label=f'Median ₹{np.median(intrinsic_values):,.0f}')
axes[0].set_title('Distribution of TCS Intrinsic Values', fontweight='bold', fontsize=13)
axes[0].set_xlabel('Intrinsic Value per Share (₹)')
axes[0].set_ylabel('Frequency')
axes[0].legend(fontsize=10)
axes[0].grid(alpha=0.3)

# CDF
sorted_vals = np.sort(intrinsic_values)
cdf = np.arange(1, len(sorted_vals) + 1) / len(sorted_vals)
axes[1].plot(sorted_vals, cdf, color='#7C3AED', linewidth=2)
axes[1].axvline(current_price, color='red', linewidth=2, linestyle='--', label=f'Market Price')
axes[1].set_title('Cumulative Distribution Function', fontweight='bold', fontsize=13)
axes[1].set_xlabel('Intrinsic Value per Share (₹)')
axes[1].set_ylabel('Cumulative Probability')
axes[1].legend(fontsize=10)
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.savefig('monte_carlo_dcf.png', dpi=150, bbox_inches='tight')
plt.show()
print("\n✅ Chart saved: monte_carlo_dcf.png")
======================================================= MONTE CARLO DCF RESULTS — TCS (10,000 simulations) ======================================================= Mean Intrinsic Value: ₹2,249 Median Intrinsic Value: ₹2,170 Std Deviation: ₹503 5th Percentile (Pessimistic): ₹1,594 25th Percentile: ₹1,900 50th Percentile (Median): ₹2,170 75th Percentile: ₹2,507 95th Percentile (Optimistic): ₹3,153 Current Market Price: ₹3,800 Probability Undervalued: 1.1% Probability Overvalued: 98.9%

📊 Understanding the Output — Line by Line

Mean vs. Median Intrinsic Value

Mean = ₹2,249 (average of all 10,000 results) and Median = ₹2,170 (the middle value when sorted). The mean is slightly higher than the median, which tells us the distribution is right-skewed — a few simulations produced very high values that pull the average up. In skewed distributions, the median is a better measure of the "typical" outcome.

Standard Deviation = ₹503

This measures how spread out the 10,000 results are. A std dev of ₹503 means most results fall within roughly ₹503 of the mean. In other words, the uncertainty in our assumptions translates into roughly ±₹503 of uncertainty in the intrinsic value. That's about ±22% — significant!

Percentiles — The Risk Spectrum

Percentiles tell you "X% of all simulations gave a value below this number."

  • P5 = ₹1,594 (Pessimistic): Only 5% of simulations produced a value below ₹1,594. This is the "bad day" scenario — if almost everything goes wrong (low growth, low margins, high WACC), the stock is worth only ~₹1,594.
  • P25 = ₹1,900: 25% of simulations fell below this. Think of it as the "somewhat negative" scenario.
  • P50 = ₹2,170 (Median): Half the simulations were above, half below. This is the "most likely" intrinsic value.
  • P75 = ₹2,507: 75% of simulations fell below this. The "somewhat positive" scenario.
  • P95 = ₹3,153 (Optimistic): Only 5% of simulations exceeded this. The "best case" — high growth, strong margins, low WACC.

The 90% confidence interval is [P5, P95] = [₹1,594 – ₹3,153]. This means: "We are 90% confident the true intrinsic value of TCS lies between ₹1,594 and ₹3,153."

The Key Answer — Probability of Undervaluation

The most important output: Only 1.1% of the 10,000 simulations produced an intrinsic value above the market price of ₹3,800. This means there's only a 1.1% chance the stock is undervalued and a 98.9% chance it's overvalued. That's a very strong overvaluation signal — the market price far exceeds what our DCF model suggests the stock is worth under almost any scenario.

🧭Investment Decision Framework

How to use these results:
P(Undervalued) > 75%: Strong buy signal — the market is likely pricing the stock below its intrinsic value in most scenarios.
P(Undervalued) 55–75%: Moderate buy — lean towards buying but proceed with caution.
P(Undervalued) 45–55%: Coin flip — the stock is fairly valued. No clear edge.
P(Undervalued) < 45%: Overvalued — more likely to lose money than gain.

Verdict for TCS: At ₹3,800, TCS appears significantly overvalued based on our DCF assumptions (P(Undervalued) = 1.1%). Even the optimistic 95th percentile (₹3,153) falls below the market price. This suggests the market may be pricing in growth expectations far higher than our model assumes — or that our assumptions (e.g., 10% revenue growth, 26% EBITDA margin) are too conservative for what the market expects from TCS.

Section 2

Monte Carlo for Project Finance & Risk Analysis

Simulating NPV, IRR, and payback for investment decisions

✏️ Worked Example 2: Factory Investment Decision

Scenario: Tata Motors is evaluating a new EV battery factory. Should they invest ₹5,000 Cr?

VariableDistributionParameters
Initial CapexTriangular₹4,500 Cr – ₹5,000 Cr – ₹6,000 Cr
Annual Revenue (Yr 1)NormalMean ₹2,000 Cr, Std ₹200 Cr
Revenue Growth (Yr 2–10)Triangular5% – 12% – 18%
Variable Costs (% of Revenue)NormalMean 60%, Std 3%
Fixed Costs (annual)Uniform₹300 Cr – ₹400 Cr
Discount RateNormalMean 12%, Std 1%
Project LifeFixed10 years
Salvage ValueTriangular₹200 Cr – ₹500 Cr – ₹800 Cr
Python — Monte Carlo Project NPV
import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)
N = 10_000

# Distributions
capex = np.random.triangular(4500, 5000, 6000, N)
rev_yr1 = np.random.normal(2000, 200, N)
rev_growth = np.random.triangular(0.05, 0.12, 0.18, N)
var_cost_pct = np.clip(np.random.normal(0.60, 0.03, N), 0.40, 0.80)
fixed_costs = np.random.uniform(300, 400, N)
discount_rate = np.clip(np.random.normal(0.12, 0.01, N), 0.08, 0.18)
salvage = np.random.triangular(200, 500, 800, N)
tax_rate = 0.2517

# Simulate
npvs = np.zeros(N)

for i in range(N):
    cash_flows = [-capex[i]]  # Year 0: investment
    revenue = rev_yr1[i]
    
    for yr in range(1, 11):
        if yr > 1:
            revenue *= (1 + rev_growth[i])
        var_cost = revenue * var_cost_pct[i]
        ebit = revenue - var_cost - fixed_costs[i]
        tax = max(ebit, 0) * tax_rate
        net_cf = ebit - tax + (capex[i] * 0.05)  # Add back depn (5% SLM)
        if yr == 10:
            net_cf += salvage[i] * (1 - tax_rate)  # After-tax salvage
        cash_flows.append(net_cf)
    
    # NPV
    npv = sum(cf / (1 + discount_rate[i])**t for t, cf in enumerate(cash_flows))
    npvs[i] = npv

# Analyze
prob_positive_npv = (npvs > 0).mean() * 100

print("=" * 55)
print("MONTE CARLO PROJECT NPV — EV Battery Factory")
print("=" * 55)
print(f"Mean NPV:     ₹{npvs.mean():,.0f} Cr")
print(f"Median NPV:   ₹{np.median(npvs):,.0f} Cr")
print(f"Std Dev:      ₹{npvs.std():,.0f} Cr")
print(f"")
print(f"5th Percentile:   ₹{np.percentile(npvs, 5):,.0f} Cr")
print(f"95th Percentile:  ₹{np.percentile(npvs, 95):,.0f} Cr")
print(f"")
print(f"Probability of Positive NPV: {prob_positive_npv:.1f}%")
print(f"Probability of NPV > ₹500 Cr: {(npvs > 500).mean()*100:.1f}%")
print(f"Probability of NPV < -₹500 Cr (loss): {(npvs < -500).mean()*100:.1f}%")

# Visualize
fig, ax = plt.subplots(figsize=(12, 6))
counts, edges, bars = ax.hist(npvs, bins=60, alpha=0.8, edgecolor='white', linewidth=0.5)
# Color bars: red for negative NPV bins, green for positive
for bar, edge in zip(bars, edges):
    bar.set_facecolor('#EF4444' if edge < 0 else '#10B981')
ax.axvline(0, color='black', linewidth=2, linestyle='-', label='Break-Even (NPV=0)')
ax.axvline(np.median(npvs), color='blue', linewidth=2, linestyle='--', 
           label=f'Median NPV ₹{np.median(npvs):,.0f} Cr')
ax.set_title('Project NPV Distribution — EV Battery Factory (10,000 Simulations)', 
             fontweight='bold', fontsize=13)
ax.set_xlabel('Net Present Value (₹ Cr)')
ax.set_ylabel('Frequency')
ax.legend(fontsize=11)
ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig('project_npv_mc.png', dpi=150, bbox_inches='tight')
plt.show()
print("\n✅ Chart saved: project_npv_mc.png")
======================================================= MONTE CARLO PROJECT NPV — EV Battery Factory ======================================================= Mean NPV: ₹266 Cr Median NPV: ₹201 Cr Std Dev: ₹963 Cr 5th Percentile: -₹1,210 Cr 95th Percentile: ₹1,916 Cr Probability of Positive NPV: 58.5% Probability of NPV > ₹500 Cr: 37.8% Probability of NPV < -₹500 Cr (loss): 21.7%
Decision

58.5% probability of positive NPV but with 21.7% chance of losing more than ₹500 Cr. This is a marginal project — more likely to succeed than fail, but the downside risk is substantial (P5 = -₹1,210 Cr). The expected value is only ₹266 Cr against a ₹5,000 Cr investment. A risk-averse management team might demand better odds (e.g., >75% probability of positive NPV) or explore ways to reduce cost uncertainty before committing.

🎯 Reusable Monte Carlo Template

Copy-paste this Python class and adapt it for ANY financial simulation — DCF, NPV, IRR, Portfolio, Revenue, M&A

📦 The MonteCarloSimulator Class

This template handles everything: defining variables, running simulations, analyzing results, and creating publication-quality charts. Copy this into any Python file or Jupyter notebook and customize.

Python — MonteCarloSimulator Template (Copy-Paste Ready)
"""
Monte Carlo Simulation Template for Financial Modeling
=======================================================
A reusable class for any Monte Carlo simulation.
Adapt the `calculate_target()` method for your specific use case.

Usage:
    sim = MonteCarloSimulator(n_simulations=10000, seed=42)
    sim.add_variable('revenue_growth', 'triangular', 0.06, 0.10, 0.14)
    sim.add_variable('wacc', 'normal', 0.11, 0.008)
    sim.run(calculate_dcf)          # Pass your calculation function
    sim.analyze(target_value=3800)  # Optional: compare to a target
    sim.plot(title='DCF Valuation', xlabel='Value per Share (₹)')
    sim.export('results.csv')
"""

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from typing import Callable, Optional, Dict, Any


class MonteCarloSimulator:
    """Reusable Monte Carlo simulation engine for financial modeling."""
    
    def __init__(self, n_simulations: int = 10000, seed: int = 42):
        self.n_simulations = n_simulations
        self.seed = seed
        self.variables: Dict[str, np.ndarray] = {}
        self.results: Optional[np.ndarray] = None
        self._variable_configs = {}  # Store for reporting
    
    def add_variable(self, name: str, distribution: str, *params):
        """
        Add a stochastic variable.
        
        Distributions:
            'normal'    → add_variable('x', 'normal', mean, std)
            'triangular'→ add_variable('x', 'triangular', min, mode, max)
            'uniform'   → add_variable('x', 'uniform', low, high)
            'lognormal' → add_variable('x', 'lognormal', mean, sigma)
            'fixed'     → add_variable('x', 'fixed', value)
        """
        np.random.seed(self.seed)
        
        if distribution == 'normal':
            mean, std = params
            values = np.random.normal(mean, std, self.n_simulations)
            self._variable_configs[name] = f'Normal(μ={mean}, σ={std})'
        elif distribution == 'triangular':
            left, mode, right = params
            values = np.random.triangular(left, mode, right, self.n_simulations)
            self._variable_configs[name] = f'Tri({left}, {mode}, {right})'
        elif distribution == 'uniform':
            low, high = params
            values = np.random.uniform(low, high, self.n_simulations)
            self._variable_configs[name] = f'Uniform({low}, {high})'
        elif distribution == 'lognormal':
            mean, sigma = params
            values = np.random.lognormal(mean, sigma, self.n_simulations)
            self._variable_configs[name] = f'LogN(μ={mean}, σ={sigma})'
        elif distribution == 'fixed':
            values = np.full(self.n_simulations, params[0])
            self._variable_configs[name] = f'Fixed({params[0]})'
        else:
            raise ValueError(f"Unknown distribution: {distribution}")
        
        self.variables[name] = values
    
    def run(self, calculation_fn: Callable[[Dict[str, np.ndarray], int], float]):
        """
        Run the simulation using the provided calculation function.
        
        Args:
            calculation_fn: A function that takes (variables_dict, iteration_index)
                           and returns a single numeric result.
        
        Example calculation function for DCF:
            def calc_dcf(vars, i):
                growth = vars['revenue_growth'][i]
                wacc = vars['wacc'][i]
                ...  # Your DCF logic here
                return intrinsic_value
        """
        self.results = np.zeros(self.n_simulations)
        for i in range(self.n_simulations):
            self.results[i] = calculation_fn(self.variables, i)
        return self
    
    def analyze(self, target_value: float = None) -> pd.DataFrame:
        """Print and return summary statistics."""
        if self.results is None:
            raise RuntimeError("Run the simulation first!")
        
        r = self.results
        stats = {
            'N Simulations': len(r),
            'Mean': r.mean(),
            'Median': np.median(r),
            'Std Dev': r.std(),
            'Min': r.min(),
            'Max': r.max(),
            'P5 (Pessimistic)': np.percentile(r, 5),
            'P25': np.percentile(r, 25),
            'P50 (Median)': np.percentile(r, 50),
            'P75': np.percentile(r, 75),
            'P95 (Optimistic)': np.percentile(r, 95),
        }
        
        print("=" * 55)
        print("MONTE CARLO SIMULATION RESULTS")
        print("=" * 55)
        print(f"\n{'Variable':<25} {'Distribution':<30}")
        print("-" * 55)
        for name, config in self._variable_configs.items():
            print(f"  {name:<25} {config:<30}")
        
        print(f"\n{'Statistic':<25} {'Value':>20}")
        print("-" * 55)
        for k, v in stats.items():
            if isinstance(v, float):
                print(f"  {k:<25} {v:>20,.2f}")
            else:
                print(f"  {k:<25} {v:>20}")
        
        if target_value is not None:
            prob_above = (r > target_value).mean() * 100
            print(f"\n  Target Value:            {target_value:>20,.2f}")
            print(f"  P(Result > Target):      {prob_above:>19.1f}%")
            print(f"  P(Result < Target):      {100-prob_above:>19.1f}%")
        
        return pd.DataFrame([stats])
    
    def plot(self, title: str = 'Monte Carlo Simulation Results',
             xlabel: str = 'Value', target_value: float = None,
             figsize: tuple = (14, 6), save_path: str = None):
        """Create histogram + CDF plots."""
        if self.results is None:
            raise RuntimeError("Run the simulation first!")
        
        fig, axes = plt.subplots(1, 2, figsize=figsize)
        r = self.results
        
        # Histogram
        axes[0].hist(r, bins=50, color='#3B82F6', alpha=0.7, edgecolor='white')
        axes[0].axvline(np.median(r), color='green', linewidth=2, linestyle='-',
                        label=f'Median: {np.median(r):,.0f}')
        if target_value is not None:
            color = 'red' if target_value < np.median(r) else 'orange'
            axes[0].axvline(target_value, color=color, linewidth=2, linestyle='--',
                            label=f'Target: {target_value:,.0f}')
        axes[0].set_title(f'{title} — Histogram', fontweight='bold')
        axes[0].set_xlabel(xlabel)
        axes[0].set_ylabel('Frequency')
        axes[0].legend()
        axes[0].grid(alpha=0.3)
        
        # CDF
        sorted_r = np.sort(r)
        cdf = np.arange(1, len(sorted_r) + 1) / len(sorted_r)
        axes[1].plot(sorted_r, cdf, color='#7C3AED', linewidth=2)
        if target_value is not None:
            axes[1].axvline(target_value, color='red', linewidth=2, linestyle='--',
                            label=f'Target: {target_value:,.0f}')
        axes[1].set_title(f'{title} — CDF', fontweight='bold')
        axes[1].set_xlabel(xlabel)
        axes[1].set_ylabel('Cumulative Probability')
        axes[1].legend()
        axes[1].grid(alpha=0.3)
        
        plt.tight_layout()
        if save_path:
            plt.savefig(save_path, dpi=150, bbox_inches='tight')
        plt.show()
    
    def export(self, filepath: str = 'monte_carlo_results.csv'):
        """Export all simulation results and variables to CSV."""
        if self.results is None:
            raise RuntimeError("Run the simulation first!")
        
        df = pd.DataFrame(self.variables)
        df['Result'] = self.results
        df.to_csv(filepath, index=False)
        print(f"✅ Results exported to {filepath} ({len(df)} rows)")
        return df
    
    def tornado(self, calculation_fn, title='Tornado Chart', figsize=(10, 6)):
        """Sensitivity analysis: vary each input ±1 std dev, hold others at mean."""
        if not self.variables:
            raise RuntimeError("Add variables first!")
        
        base_vars = {k: v.mean() for k, v in self.variables.items()}
        
        sensitivities = []
        for name, values in self.variables.items():
            std = values.std()
            if std == 0:
                continue
            
            # Low: set this var to mean-1std, others at mean
            low_vars = {k: (v.mean() if k != name else v.mean() - std) 
                       for k, v in self.variables.items()}
            low_result = calculation_fn(low_vars, 0)
            
            # High: set this var to mean+1std
            high_vars = {k: (v.mean() if k != name else v.mean() + std) 
                        for k, v in self.variables.items()}
            high_result = calculation_fn(high_vars, 0)
            
            sensitivities.append({
                'Variable': name,
                'Low Result': low_result,
                'High Result': high_result,
                'Swing': abs(high_result - low_result)
            })
        
        # Sort by swing
        df_tornado = pd.DataFrame(sensitivities).sort_values('Swing')
        
        fig, ax = plt.subplots(figsize=figsize)
        for _, row in df_tornado.iterrows():
            ax.barh(row['Variable'], row['High Result'] - row['Low Result'],
                    left=row['Low Result'], color='#3B82F6', alpha=0.7, edgecolor='white')
        ax.set_title(title, fontweight='bold', fontsize=13)
        ax.set_xlabel('Output Value')
        ax.grid(alpha=0.3, axis='x')
        plt.tight_layout()
        plt.show()
        
        return df_tornado


# ========================================================
# EXAMPLE USAGE: DCF Valuation with the Template
# ========================================================
if __name__ == '__main__':
    
    # 1. Create simulator
    sim = MonteCarloSimulator(n_simulations=10000, seed=42)
    
    # 2. Define stochastic variables
    sim.add_variable('rev_growth', 'triangular', 0.06, 0.10, 0.14)
    sim.add_variable('ebitda_margin', 'normal', 0.26, 0.015)
    sim.add_variable('wacc', 'normal', 0.11, 0.008)
    sim.add_variable('terminal_growth', 'triangular', 0.04, 0.055, 0.07)
    
    # Fixed values (still added for the calculation function to use)
    sim.add_variable('base_revenue', 'fixed', 240_000)
    sim.add_variable('net_debt', 'fixed', 8_000)
    sim.add_variable('shares', 'fixed', 361)
    
    # 3. Define the DCF calculation function
    def calculate_dcf(vars, i):
        revenue = vars['base_revenue'][i]
        fcff_list = []
        for yr in range(1, 6):
            revenue *= (1 + vars['rev_growth'][i])
            ebitda = revenue * vars['ebitda_margin'][i]
            nopat = ebitda * (1 - 0.2517)
            fcff = nopat + revenue * 0.03 - revenue * 0.04 - revenue * 0.02
            fcff_list.append(fcff)
        
        terminal_value = fcff_list[-1] * (1 + vars['terminal_growth'][i]) / \
                        (vars['wacc'][i] - vars['terminal_growth'][i])
        
        pv_fcff = sum(f / (1 + vars['wacc'][i])**t 
                     for t, f in enumerate(fcff_list, 1))
        pv_tv = terminal_value / (1 + vars['wacc'][i])**5
        
        ev = pv_fcff + pv_tv
        return (ev - vars['net_debt'][i]) / vars['shares'][i]
    
    # 4. Run simulation
    sim.run(calculate_dcf)
    
    # 5. Analyze
    sim.analyze(target_value=3800)
    
    # 6. Visualize
    sim.plot(title='TCS DCF Valuation', xlabel='Intrinsic Value per Share (₹)',
             target_value=3800, save_path='template_dcf_mc.png')
    
    # 7. Export
    sim.export('tcs_monte_carlo_results.csv')
    
    # 8. Tornado chart (sensitivity)
    # sim.tornado(calculate_dcf, title='DCF Sensitivity — TCS')
📋How to Adapt the Template

For any scenario, just change 3 things:
1. add_variable() — define your uncertain inputs and distributions
2. calculation_fn — write your specific formula (DCF, NPV, IRR, etc.)
3. target_value — what threshold to compare against

Everything else (simulation engine, statistics, charts, export) stays the same!

Python Lab

Hands-On Practice Exercises

🏋️ Exercise 1: Estimating π with Monte Carlo (15 min)

Objective: Build intuition by estimating the value of π using random sampling — the classic Monte Carlo introduction.

Method: Generate random points in a 2×2 square. Count how many fall inside the unit circle. π ≈ 4 × (points inside circle) / (total points).

Python — Estimate π
import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)
N = 100_000

x = np.random.uniform(-1, 1, N)
y = np.random.uniform(-1, 1, N)
inside = (x**2 + y**2) <= 1

pi_estimate = 4 * inside.sum() / N
print(f"Estimated π: {pi_estimate:.6f}")
print(f"Actual π:    {np.pi:.6f}")
print(f"Error:       {abs(pi_estimate - np.pi):.6f}")

# Visualize
fig, ax = plt.subplots(figsize=(6, 6))
ax.scatter(x[inside], y[inside], c='#3B82F6', s=0.5, alpha=0.5)
ax.scatter(x[~inside], y[~inside], c='#EF4444', s=0.5, alpha=0.5)
ax.set_aspect('equal')
ax.set_title(f'Monte Carlo π Estimation (N={N:,})\nπ ≈ {pi_estimate:.4f}', fontweight='bold')
plt.tight_layout()
plt.show()
Estimated π: 3.141680 Actual π: 3.141593 Error: 0.000087

🏋️ Exercise 2: Monte Carlo DCF for Infosys (25 min)

Objective: Use the template to value Infosys. Fetch data with yfinance, set up distributions, run 10,000 simulations.

Distributions to use:

  • Revenue Growth: Triangular(5%, 9%, 13%)
  • EBITDA Margin: Normal(23%, 2%)
  • WACC: Normal(10.5%, 0.7%)
  • Terminal Growth: Triangular(3.5%, 5%, 6.5%)
Python — Infosys Monte Carlo DCF
# Use the MonteCarloSimulator template from Section 3
# Just change the parameters!

sim = MonteCarloSimulator(n_simulations=10000, seed=42)

# Infosys-specific assumptions
sim.add_variable('rev_growth', 'triangular', 0.05, 0.09, 0.13)
sim.add_variable('ebitda_margin', 'normal', 0.23, 0.02)
sim.add_variable('wacc', 'normal', 0.105, 0.007)
sim.add_variable('terminal_growth', 'triangular', 0.035, 0.05, 0.065)
sim.add_variable('base_revenue', 'fixed', 165_000)   # ₹ Cr
sim.add_variable('net_debt', 'fixed', 10_000)         # ₹ Cr
sim.add_variable('shares', 'fixed', 2_600)             # Cr shares

def calc_infosys_dcf(vars, i):
    revenue = vars['base_revenue'][i]
    fcff_list = []
    for yr in range(1, 6):
        revenue *= (1 + vars['rev_growth'][i])
        ebitda = revenue * vars['ebitda_margin'][i]
        nopat = ebitda * (1 - 0.2517)
        fcff = nopat + revenue * 0.02 - revenue * 0.03 - revenue * 0.01
        fcff_list.append(fcff)
    tv = fcff_list[-1] * (1 + vars['terminal_growth'][i]) / \
         (vars['wacc'][i] - vars['terminal_growth'][i])
    pv = sum(f / (1 + vars['wacc'][i])**t for t, f in enumerate(fcff_list, 1))
    pv_tv = tv / (1 + vars['wacc'][i])**5
    return (pv + pv_tv - vars['net_debt'][i]) / vars['shares'][i]

sim.run(calc_infosys_dcf)
sim.analyze(target_value=1500)  # Current Infosys price ~₹1,500
sim.plot(title='Infosys DCF Valuation', xlabel='Value per Share (₹)',
         target_value=1500, save_path='infosys_mc.png')
sim.export('infosys_mc_results.csv')

🏋️ Exercise 3: Portfolio Risk Simulation (20 min)

Objective: Simulate a 3-stock portfolio (TCS 40%, Infosys 30%, HDFC Bank 30%) over 1 year. Find VaR at 95% confidence.

Python — Portfolio Monte Carlo VaR
import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)
N = 50_000

# Portfolio weights
weights = np.array([0.40, 0.30, 0.30])

# Annual returns and covariance (from historical data)
means = np.array([0.15, 0.12, 0.10])  # Expected returns
stds = np.array([0.25, 0.22, 0.20])    # Std devs
correlation = np.array([
    [1.0, 0.7, 0.5],
    [0.7, 1.0, 0.6],
    [0.5, 0.6, 1.0]
])
cov_matrix = np.outer(stds, stds) * correlation

# Simulate correlated returns using Cholesky decomposition
L = np.linalg.cholesky(cov_matrix)
Z = np.random.normal(0, 1, (N, 3))
correlated_Z = Z @ L.T
simulated_returns = means + correlated_Z  # Add mean

# Portfolio returns
portfolio_returns = simulated_returns @ weights

# Value at Risk
investment = 1_00_00_000  # ₹1 Crore
var_95 = np.percentile(portfolio_returns, 5) * investment
var_99 = np.percentile(portfolio_returns, 1) * investment
cvar_95 = portfolio_returns[portfolio_returns <= np.percentile(portfolio_returns, 5)].mean() * investment

print("PORTFOLIO RISK ANALYSIS (₹1 Crore Investment)")
print("=" * 50)
print(f"Expected Return:     {portfolio_returns.mean()*100:.2f}%")
print(f"Std Deviation:       {portfolio_returns.std()*100:.2f}%")
print(f"")
print(f"Value at Risk (95%): ₹{abs(var_95):>12,.0f}  (max loss on 1 worst day in 20)")
print(f"Value at Risk (99%): ₹{abs(var_99):>12,.0f}  (max loss on 1 worst day in 100)")
print(f"CVaR / Expected Shortfall: ₹{abs(cvar_95):>12,.0f}")
print(f"")
print(f"Best case (99th %):  +₹{np.percentile(portfolio_returns, 99)*investment:>12,.0f}")
print(f"Worst case (1st %):  -₹{abs(np.percentile(portfolio_returns, 1)*investment):>12,.0f}")

# Plot
fig, ax = plt.subplots(figsize=(12, 6))
losses = portfolio_returns * investment / 1e5  # In ₹ lakhs
ax.hist(losses, bins=80, color='#3B82F6', alpha=0.7, edgecolor='white')
ax.axvline(var_95/1e5, color='red', linewidth=2, linestyle='--', label=f'VaR 95%: ₹{abs(var_95/1e5):.1f}L')
ax.axvline(0, color='black', linewidth=1.5, label='Break-Even')
ax.set_title('Portfolio P&L Distribution — ₹1 Cr Investment', fontweight='bold')
ax.set_xlabel('Profit / Loss (₹ Lakhs)')
ax.set_ylabel('Frequency')
ax.legend()
ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig('portfolio_var.png', dpi=150)
plt.show()

🏋️ Exercise 4 (Advanced): M&A Deal Simulation (20 min)

Objective: Simulate an M&A acquisition. Vary the premium (20–50%), synergies (₹100–₹500 Cr), integration costs, and financing costs. Find the probability that the deal is accretive to EPS.

Use the MonteCarloSimulator template. Write a calculation function that computes pro forma EPS and checks if it exceeds the acquirer's standalone EPS.

Python — M&A Accretion/Dilution Monte Carlo
sim = MonteCarloSimulator(n_simulations=10000, seed=42)

sim.add_variable('premium', 'triangular', 0.20, 0.35, 0.50)
sim.add_variable('synergies', 'triangular', 100, 300, 500)  # ₹ Cr
sim.add_variable('integration_cost', 'triangular', 50, 150, 300)
sim.add_variable('interest_rate', 'normal', 0.09, 0.01)
sim.add_variable('target_ebitda', 'normal', 200, 20)
sim.add_variable('acquirer_shares', 'fixed', 100)    # Cr
sim.add_variable('target_shares', 'fixed', 12)        # Cr
sim.add_variable('target_price', 'fixed', 800)        # per share
sim.add_variable('acquirer_eps', 'fixed', 50)         # standalone EPS

def calc_ma_accretion(vars, i):
    premium = vars['premium'][i]
    synergies = vars['synergies'][i]
    int_cost = vars['integration_cost'][i]
    rate = vars['interest_rate'][i]
    target_ebitda = vars['target_ebitda'][i]
    
    offer_price = vars['target_price'][i] * (1 + premium)
    equity_value = offer_price * vars['target_shares'][i]
    cash_portion = equity_value * 0.60
    debt_raised = cash_portion
    interest_expense = debt_raised * rate
    
    combined_ebitda = 1000 + target_ebitda + synergies  # Acquirer ₹1000 Cr
    combined_ebit = combined_ebitda - 100 - int_cost
    earnings = (combined_ebit - 200 - interest_expense) * (1 - 0.2517)
    
    new_shares = vars['acquirer_shares'][i] + (equity_value * 0.40) / 850
    pro_forma_eps = earnings / new_shares
    
    return pro_forma_eps - vars['acquirer_eps'][i]  # EPS accretion/dilution

sim.run(calc_ma_accretion)
sim.analyze(target_value=0)  # 0 = breakeven for EPS
sim.plot(title='M&A EPS Accretion/Dilution', xlabel='EPS Impact (₹)',
         target_value=0, save_path='ma_accretion_mc.png')
Quick Review

📚 Key Terms — Click to Flip

Knowledge Check

Test Your Understanding

10 questions on Monte Carlo Simulation

Summary

Key Takeaways

📝 What We Covered Today

  • Monte Carlo fundamentals — replacing single assumptions with probability distributions to quantify uncertainty
  • Common distributions — Normal (margins, WACC), Triangular (growth, capex), Lognormal (stock prices), Uniform (range-only estimates)
  • DCF simulation — ran 10,000 DCF valuations for TCS, finding only 1.1% probability of undervaluation at ₹3,800 (median intrinsic value ₹2,170)
  • Project finance simulation — evaluated a ₹5,000 Cr EV battery factory with 58.5% probability of positive NPV, highlighting the importance of quantifying downside risk
  • Reusable template — a copy-paste-ready MonteCarloSimulator class that works for DCF, NPV, portfolio VaR, M&A accretion, and more
  • Portfolio VaR — used Cholesky decomposition to simulate correlated asset returns and compute Value at Risk
📚Next Session

Session 23: Real Options Valuation
We'll learn how to value flexibility — the option to expand, abandon, or delay a project using binomial trees and Black-Scholes. Real options explain why NPV alone can miss strategic value.