What You'll Learn Today
Monte Carlo Simulation Fundamentals
From the casino to Wall Street — understanding stochastic modeling
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.
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" |
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:
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.
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%."
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.
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.
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."
1. DEFINE → Build your financial model (DCF, NPV, IRR, etc.)2. ASSIGN → Replace fixed assumptions with probability distributions3. SIMULATE → Run the model 10,000+ times with random inputs4. ANALYZE → Study the distribution: mean, median, percentiles, confidence intervals5. DECIDE → Make decisions based on probabilities, not gut feelings
📐 Common Probability Distributions for Finance
| Distribution | Shape | Use Case | Python (NumPy) |
|---|---|---|---|
| Normal (Gaussian) | Bell curve, symmetric | WACC, margins, returns | np.random.normal(mean, std, N) |
| Triangular | Triangle: min–mode–max | Growth rates, capex estimates | np.random.triangular(left, mode, right, N) |
| Uniform | Flat: all values equally likely | When you only know the range | np.random.uniform(low, high, N) |
| Lognormal | Right-skewed, always positive | Stock prices, revenue (can't be negative) | np.random.lognormal(mean, sigma, N) |
| PERT | Smooth bell-like triangular | Project management estimates | Custom (see template) |
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
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 Variable | Distribution | Parameters | Why This Distribution? |
|---|---|---|---|
| Revenue Growth (Yr 1–5) | Triangular | Min 6%, Mode 10%, Max 14% | Management guidance suggests ~10% growth, but could range 6–14%. Triangular captures this "best guess with bounds." |
| EBITDA Margin | Normal | Mean 26%, Std 1.5% | TCS has historically maintained ~26% margins with small fluctuations. Normal distribution captures this stable-but-slightly-variable pattern. |
| WACC | Normal | Mean 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 Growth | Triangular | Min 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 Outstanding | Fixed | 361 Cr shares | Known from the latest annual report — no uncertainty. |
| Net Debt | Fixed | ₹8,000 Cr | Known from the balance sheet — no uncertainty. |
Step 2: What the Simulation Will Do
The code below will repeat this process 10,000 times:
① 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.
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")
📊 Understanding the Output — Line by Line
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.
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 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 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.
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.
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?
| Variable | Distribution | Parameters |
|---|---|---|
| Initial Capex | Triangular | ₹4,500 Cr – ₹5,000 Cr – ₹6,000 Cr |
| Annual Revenue (Yr 1) | Normal | Mean ₹2,000 Cr, Std ₹200 Cr |
| Revenue Growth (Yr 2–10) | Triangular | 5% – 12% – 18% |
| Variable Costs (% of Revenue) | Normal | Mean 60%, Std 3% |
| Fixed Costs (annual) | Uniform | ₹300 Cr – ₹400 Cr |
| Discount Rate | Normal | Mean 12%, Std 1% |
| Project Life | Fixed | 10 years |
| Salvage Value | Triangular | ₹200 Cr – ₹500 Cr – ₹800 Cr |
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")
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.
📦 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.
"""
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')
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!
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).
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()
🏋️ 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%)
# 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.
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.
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')
📚 Key Terms — Click to Flip
Test Your Understanding
10 questions on Monte Carlo Simulation
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
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.