Scenario Planning & Sensitivity Analysis
Move beyond single-point forecasts — build bull, bear, and base cases, quantify uncertainty with Monte Carlo simulation, and identify the assumptions that truly drive value.
Learning Objectives
- Construct base, bull, and bear case scenarios with internally consistent assumptions
- Compute probability-weighted expected values for valuation under uncertainty
- Build tornado charts that rank value drivers by their impact on valuation outcomes
- Implement Monte Carlo simulation to generate a full probability distribution of valuation outcomes
- Communicate uncertainty to stakeholders through fan charts, histograms, and scenario tables
8.1 Why Point Forecasts Are Dangerous
In Chapter 7, you built a single "best estimate" revenue and margin forecast. That forecast is your base case — your most likely outcome given what you know today. But no analyst, no matter how skilled, can predict the future with certainty. The base case is the center of a probability distribution, not the only possible future.
Consider what can deviate from a forecast:
- Macroeconomic shocks: A global recession cuts demand by 15%. A commodity price spike doubles raw material costs. Interest rates rise 300 basis points.
- Competitive disruptions: A new entrant undercuts prices. A technology shift makes the company's product obsolete. Industry consolidation changes the bargaining landscape.
- Regulatory changes: New environmental regulations require massive capex. Tax rates change. Import duties affect supply chains. SEBI mandates new compliance costs.
- Company-specific events: A major product launch succeeds beyond expectations. A key customer is lost. A factory suffers an extended shutdown. Management changes strategy.
Each of these can move revenue, margins, or capital costs materially away from the base case. A valuation that does not account for this range of possibilities is incomplete — and potentially dangerous if used for investment decisions.
8.2 The Three-Scenario Framework: Base, Bull, Bear
The simplest and most widely used scenario framework defines three cases. Each scenario must be internally consistent — you cannot have a bull case with 25% revenue growth and flat working capital, or a bear case with falling margins and rising dividends.
| Component | Base Case | Bull Case | Bear Case |
|---|---|---|---|
| Probability | 50–60% | 20–25% | 20–25% |
| Philosophy | "Most likely" — business as usual, moderate growth, stable margins | "Everything goes right" — demand exceeds expectations, margins expand, competition benign | "Everything goes wrong" — recession, competitive pressure, cost inflation, regulatory headwinds |
| Revenue Growth | Historical average, adjusted for industry outlook | Above trend — market share gains, new product success, favorable macro | Below trend or negative — demand destruction, market share loss |
| Margins | Stable at historical levels | Expanding — operating leverage, pricing power, cost reduction | Contracting — price cuts, cost inflation, negative operating leverage |
| WACC | Current estimated WACC | Lower — falling interest rates, lower equity risk premium | Higher — rising rates, higher risk premium, credit spread widening |
| Capex / Investment | Maintenance + planned growth | Higher investment — funding growth opportunities | Cut to maintenance only — cash preservation |
8.2.1 Python: Building Three Scenarios
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
def build_three_scenarios(base_revenue_growth, base_op_margin,
base_wacc=0.10, forecast_years=5,
bull_premium=0.30, bear_discount=0.30):
"""
Build base, bull, and bear case scenarios from a base forecast.
Parameters
----------
base_revenue_growth : float — base case annual revenue growth rate
base_op_margin : float — base case operating margin (%)
base_wacc : float — base case WACC
forecast_years : int
bull_premium : float — how much better is the bull case (e.g., 0.30 = 30% better)
bear_discount : float — how much worse is the bear case
Returns
-------
DataFrame with revenue, EBIT, and implied value for each scenario-year.
"""
last_revenue = 100 # Normalize: start from 100
scenarios = {}
for name, growth_adj, margin_adj, wacc_adj, prob in [
('Base', 1.00, 1.00, 1.00, 0.55),
('Bull', 1 + bull_premium, 1 + bull_premium * 0.5, 1 - bull_premium * 0.3, 0.20),
('Bear', 1 - bear_discount, 1 - bear_discount * 0.7, 1 + bear_discount * 0.5, 0.25)
]:
rev_g = base_revenue_growth * growth_adj
op_m = base_op_margin * margin_adj
wacc = base_wacc * wacc_adj
revenues = [last_revenue]
ebits = []
for y in range(1, forecast_years + 1):
revenues.append(revenues[-1] * (1 + rev_g))
ebits.append(revenues[-1] * op_m / 100)
# Simple perpetuity value at Year 5 (terminal value proxy)
terminal_value = ebits[-1] * (1 - base_wacc) / base_wacc # simplified
pv_terminal = terminal_value / (1 + wacc) ** forecast_years
# PV of EBIT stream
pv_ebit = sum(ebit / (1 + wacc) ** (y+1) for y, ebit in enumerate(ebits))
scenarios[name] = {
'Probability': prob,
'Revenue Growth %': rev_g * 100,
'Op Margin %': op_m,
'WACC %': wacc * 100,
'Year5 Revenue': revenues[-1],
'Year5 EBIT': ebits[-1],
'Approx PV': pv_ebit + pv_terminal,
'Revenue Path': revenues[1:],
'EBIT Path': ebits
}
return scenarios
# --- Build scenarios for a company ---
# Assume base: 12% revenue growth, 20% operating margin, 10% WACC
scenarios = build_three_scenarios(
base_revenue_growth=0.12,
base_op_margin=20.0,
base_wacc=0.10
)
print("=== THREE-SCENARIO ANALYSIS ===")
for name, s in scenarios.items():
print(f"\n{name.upper()} CASE (Probability: {s['Probability']*100:.0f}%)")
print(f" Revenue Growth: {s['Revenue Growth %']:.1f}%")
print(f" Operating Margin: {s['Op Margin %']:.1f}%")
print(f" WACC: {s['WACC %']:.1f}%")
print(f" Year 5 Revenue: {s['Year5 Revenue']:.1f}")
print(f" Year 5 EBIT: {s['Year5 EBIT']:.1f}")
print(f" Approx PV: {s['Approx PV']:.1f}")
# Probability-weighted expected value
expected_value = sum(s['Approx PV'] * s['Probability'] for s in scenarios.values())
print(f"\nProbability-Weighted Expected Value: {expected_value:.1f}")
print(f"Base Case Value: {scenarios['Base']['Approx PV']:.1f}")
print(f"Difference (Expected - Base): {expected_value - scenarios['Base']['Approx PV']:.1f}")
print(f" → A positive difference means the upside exceeds the downside (skewed right)")
print(f" → A negative difference means downside risk dominates (skewed left)")
8.3 Visualizing Scenarios: The Fan Chart
A fan chart shows the range of possible outcomes over time, with the base case as the central line and the bull/bear cases fanning outward. It is the most intuitive way to communicate forecast uncertainty to non-technical audiences.
def plot_scenario_fan(scenarios, metric='Revenue Path', forecast_years=5):
"""Plot a fan chart showing the range of outcomes across scenarios."""
fig, ax = plt.subplots(figsize=(12, 7))
years = ['Y0'] + [f'Y{y}' for y in range(1, forecast_years + 1)]
colors = {'Base': '#6c8cff', 'Bull': '#00c9a7', 'Bear': '#e0556a'}
for name, s in scenarios.items():
if metric == 'Revenue Path':
values = [100] + list(s['Revenue Path'])
else:
values = [None] + list(s['EBIT Path'])
ax.plot(range(len(years)), values, 'o-', color=colors[name],
linewidth=3 if name == 'Base' else 2,
markersize=8 if name == 'Base' else 6,
label=f"{name} (P={s['Probability']*100:.0f}%)")
# Shade between bull and bear
y_bull = [100] + list(scenarios['Bull']['Revenue Path'])
y_bear = [100] + list(scenarios['Bear']['Revenue Path'])
ax.fill_between(range(len(years)), y_bear, y_bull,
alpha=0.12, color='#6c8cff', label='Bull–Bear Range')
ax.set_xticks(range(len(years)))
ax.set_xticklabels(years)
ax.set_ylabel('Revenue (Index: Year 0 = 100)')
ax.set_title('Scenario Fan Chart — Revenue Projections', fontweight='bold', fontsize=14)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
# Add annotations
ax.annotate(f"Bull: {scenarios['Bull']['Revenue Growth %']:.0f}% growth",
xy=(forecast_years, y_bull[-1]),
textcoords="offset points", xytext=(10, 15), fontsize=9,
color=colors['Bull'], fontweight='bold')
ax.annotate(f"Base: {scenarios['Base']['Revenue Growth %']:.0f}% growth",
xy=(forecast_years, [100] + list(scenarios['Base']['Revenue Path']))[-1],
textcoords="offset points", xytext=(10, 0), fontsize=9,
color=colors['Base'], fontweight='bold')
ax.annotate(f"Bear: {scenarios['Bear']['Revenue Growth %']:.0f}% growth",
xy=(forecast_years, y_bear[-1]),
textcoords="offset points", xytext=(10, -15), fontsize=9,
color=colors['Bear'], fontweight='bold')
plt.tight_layout()
plt.show()
plot_scenario_fan(scenarios)
8.4 Sensitivity Analysis: Which Assumptions Matter Most?
Not all assumptions are equally important. A 1% change in revenue growth might swing the valuation by 15%, while a 1% change in the tax rate might move it by 2%. Sensitivity analysis identifies the assumptions that deserve your deepest scrutiny — the ones where getting it wrong costs the most.
A valuation model might have 20+ input assumptions (growth rates, margins, capex, working capital, WACC components, terminal growth). Sensitivity analysis answers: which 2 or 3 of these actually drive the result?
8.4.1 One-Way Sensitivity: Tornado Charts
A tornado chart shows how the valuation changes when each assumption is varied individually across a plausible range, holding all others at their base case. The assumptions are sorted by impact, creating the characteristic "tornado" shape.
def tornado_chart(base_assumptions, sensitivities, base_value, metric_name='Enterprise Value'):
"""
Build a tornado chart showing the impact of each assumption on valuation.
Parameters
----------
base_assumptions : dict — {assumption_name: base_value}
sensitivities : dict — {assumption_name: (low_pct_change, high_pct_change)}
e.g., {'Revenue Growth': (-0.20, +0.20)} means test 20% below and above base
base_value : float — valuation under base assumptions
metric_name : str
Returns
-------
DataFrame sorted by impact, and a matplotlib tornado chart.
"""
results = []
for name, base in base_assumptions.items():
low_pct, high_pct = sensitivities[name]
low_val = base * (1 + low_pct)
high_val = base * (1 + high_pct)
# Simple proportional impact model (in practice, re-run your DCF)
# Here we use a proxy: value impact ≈ base_value × sensitivity_factor × Δassumption
sensitivity_factor = 1.5 if 'growth' in name.lower() else \
1.2 if 'margin' in name.lower() else \
0.8 if 'wacc' in name.lower() else 1.0
low_impact = base_value * sensitivity_factor * low_pct
high_impact = base_value * sensitivity_factor * high_pct
# For WACC-type variables, the relationship is inverted
if 'wacc' in name.lower() or 'cost' in name.lower():
low_impact, high_impact = -high_impact, -low_impact
results.append({
'Assumption': name,
'Low': round(low_impact, 1),
'High': round(high_impact, 1),
'Range': round(abs(high_impact - low_impact), 1)
})
tornado_df = pd.DataFrame(results).sort_values('Range', ascending=True)
# Plot
fig, ax = plt.subplots(figsize=(12, 7))
y_pos = range(len(tornado_df))
ax.barh(y_pos, tornado_df['High'] - base_value, left=base_value,
height=0.6, color='#00c9a7', alpha=0.85, label='Upside')
ax.barh(y_pos, tornado_df['Low'] - base_value, left=base_value,
height=0.6, color='#e0556a', alpha=0.85, label='Downside')
ax.set_yticks(y_pos)
ax.set_yticklabels(tornado_df['Assumption'], fontsize=10)
ax.axvline(x=base_value, color='#6c8cff', linewidth=2.5, linestyle='--',
label=f'Base: {base_value:.0f}')
ax.set_xlabel(metric_name)
ax.set_title('Tornado Chart — Sensitivity of Valuation to Key Assumptions',
fontweight='bold', fontsize=14)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.2, axis='x')
plt.tight_layout()
plt.show()
return tornado_df
# --- Define assumptions and their test ranges ---
base_assumptions = {
'Revenue Growth': 0.12,
'Operating Margin': 0.20,
'WACC': 0.10,
'Capex / Revenue': 0.06,
'Working Capital / Revenue': 0.15,
'Tax Rate': 0.25,
'Terminal Growth': 0.03,
}
sensitivities = {
'Revenue Growth': (-0.25, +0.25), # ±25% of base value → test 9% and 15%
'Operating Margin': (-0.20, +0.20),
'WACC': (-0.15, +0.15),
'Capex / Revenue': (-0.30, +0.30),
'Working Capital / Revenue': (-0.30, +0.30),
'Tax Rate': (-0.20, +0.20),
'Terminal Growth': (-0.50, +0.50),
}
base_value = 1000 # Assume base case valuation = 1000
tornado = tornado_chart(base_assumptions, sensitivities, base_value)
print(tornado[['Assumption', 'Low', 'High', 'Range']].to_string(index=False))
8.5 Two-Way Sensitivity: The Heatmap
A two-way sensitivity table shows how valuation changes when two assumptions vary simultaneously. It is the single most valuable diagnostic for understanding your model's behavior. The standard format is a heatmap with Revenue Growth on one axis and Operating Margin (or WACC) on the other.
def two_way_sensitivity(var1_name, var1_values, var2_name, var2_values,
base_assumptions, base_value):
"""
Build a two-way sensitivity table.
Returns a matrix of valuation outcomes for every combination of var1 and var2.
"""
matrix = np.zeros((len(var1_values), len(var2_values)))
for i, v1 in enumerate(var1_values):
for j, v2 in enumerate(var2_values):
# Compute proportional impact (in practice, re-run DCF)
impact1 = (v1 - base_assumptions[var1_name]) / base_assumptions[var1_name] * 1.5
impact2 = (v2 - base_assumptions[var2_name]) / base_assumptions[var2_name] * 1.2
if 'wacc' in var1_name.lower(): impact1 = -impact1
if 'wacc' in var2_name.lower(): impact2 = -impact2
matrix[i, j] = base_value * (1 + impact1 + impact2)
return matrix
def plot_two_way_heatmap(matrix, var1_name, var1_values, var2_name, var2_values, base_value):
"""Plot a two-way sensitivity heatmap."""
fig, ax = plt.subplots(figsize=(12, 8))
# Format labels
v1_labels = [f'{v*100:.0f}%' for v in var1_values]
v2_labels = [f'{v*100:.0f}%' for v in var2_values]
sns.heatmap(matrix, annot=True, fmt='.0f', cmap='RdYlGn',
xticklabels=v2_labels, yticklabels=v1_labels,
center=base_value, linewidths=0.5, ax=ax,
cbar_kws={'label': 'Enterprise Value'})
ax.set_xlabel(var2_name, fontsize=11)
ax.set_ylabel(var1_name, fontsize=11)
ax.set_title(f'Two-Way Sensitivity: {var1_name} vs {var2_name}\n'
f'(Base Value = {base_value:.0f})',
fontweight='bold', fontsize=13)
plt.tight_layout()
plt.show()
# --- Revenue Growth vs Operating Margin ---
rev_growth_values = [0.08, 0.10, 0.12, 0.15, 0.18]
op_margin_values = [0.15, 0.18, 0.20, 0.23, 0.25]
matrix = two_way_sensitivity(
'Revenue Growth', rev_growth_values,
'Operating Margin', op_margin_values,
base_assumptions, base_value
)
plot_two_way_heatmap(matrix,
'Revenue Growth', rev_growth_values,
'Operating Margin', op_margin_values, base_value)
# --- Revenue Growth vs WACC ---
wacc_values = [0.08, 0.09, 0.10, 0.11, 0.12]
matrix2 = two_way_sensitivity(
'Revenue Growth', rev_growth_values,
'WACC', wacc_values,
base_assumptions, base_value
)
plot_two_way_heatmap(matrix2,
'Revenue Growth', rev_growth_values,
'WACC', wacc_values, base_value)
8.6 Monte Carlo Simulation: The Full Probability Distribution
Scenarios (Section 8.2) give you three discrete outcomes. Sensitivity analysis (Sections 8.4–8.5) varies one or two assumptions at a time. Monte Carlo simulation goes further: it varies all assumptions simultaneously, thousands of times, drawing each from a probability distribution you specify. The result is not a single value or a range, but a full probability distribution of valuation outcomes.
8.6.1 How Monte Carlo Works
8.6.2 Implementing Monte Carlo in Python
def monte_carlo_valuation(n_simulations=10000,
seed=42,
last_revenue=100,
forecast_years=5,
terminal_growth=0.03):
"""
Monte Carlo simulation of a simplified DCF valuation.
Each simulation draws random values for:
- Revenue growth rate
- Operating margin
- WACC
- Capex / Revenue
- Working capital / Revenue
Returns a DataFrame of simulation results and paths.
"""
np.random.seed(seed)
results = []
for sim in range(n_simulations):
# Draw random inputs from distributions
rev_growth = np.random.normal(0.12, 0.04) # mean 12%, σ 4%
op_margin = np.random.normal(0.20, 0.03) # mean 20%, σ 3%
wacc = np.random.triangular(0.07, 0.10, 0.14) # min 7%, mode 10%, max 14%
capex_pct = np.random.normal(0.06, 0.02) # capex as % of revenue
wc_pct = np.random.normal(0.15, 0.05) # working capital as % of revenue
tax_rate = 0.25
# Build revenue and FCFF path
revenues = [last_revenue]
fcffs = []
for y in range(1, forecast_years + 1):
revenues.append(revenues[-1] * (1 + rev_growth))
ebit = revenues[-1] * op_margin
nopat = ebit * (1 - tax_rate)
capex = revenues[-1] * capex_pct
delta_wc = (revenues[-1] - revenues[-2]) * wc_pct
fcff = nopat - capex - delta_wc
fcffs.append(fcff)
# Terminal value (perpetuity growth)
terminal_fcff = fcffs[-1] * (1 + terminal_growth)
terminal_value = terminal_fcff / (wacc - terminal_growth)
# Discount FCFFs and terminal value
pv_fcff = sum(fcff / (1 + wacc) ** (y + 1) for y, fcff in enumerate(fcffs))
pv_terminal = terminal_value / (1 + wacc) ** forecast_years
enterprise_value = pv_fcff + pv_terminal
results.append({
'sim': sim,
'rev_growth': rev_growth * 100,
'op_margin': op_margin * 100,
'wacc': wacc * 100,
'enterprise_value': enterprise_value,
'year5_revenue': revenues[-1],
})
return pd.DataFrame(results)
# --- Run 10,000 simulations ---
mc_results = monte_carlo_valuation(n_simulations=10000)
# --- Summary statistics ---
print("=== MONTE CARLO SIMULATION RESULTS (10,000 trials) ===")
print(f"\nEnterprise Value Distribution:")
for pct in [5, 10, 25, 50, 75, 90, 95]:
val = np.percentile(mc_results['enterprise_value'], pct)
print(f" P{pct}: {val:.1f}")
print(f"\n Mean: {mc_results['enterprise_value'].mean():.1f}")
print(f" Median: {mc_results['enterprise_value'].median():.1f}")
print(f" Std Dev: {mc_results['enterprise_value'].std():.1f}")
print(f" Skewness: {mc_results['enterprise_value'].skew():.2f}")
# Probability that value > 1000
prob_above_1000 = (mc_results['enterprise_value'] > 1000).mean() * 100
print(f"\n P(Value > 1000): {prob_above_1000:.1f}%")
print(f" P(Value < 800): {(mc_results['enterprise_value'] < 800).mean() * 100:.1f}%")
8.6.3 Visualizing the Monte Carlo Distribution
def plot_monte_carlo_results(mc_results, base_value=None):
"""Visualize Monte Carlo results: histogram, CDF, and input distributions."""
fig, axes = plt.subplots(2, 2, figsize=(16, 12))
# Chart 1: Histogram of Enterprise Values
ax = axes[0, 0]
ax.hist(mc_results['enterprise_value'], bins=80, color='#6c8cff',
alpha=0.8, edgecolor='white', linewidth=0.3)
if base_value:
ax.axvline(x=base_value, color='#e0556a', linestyle='--', linewidth=2.5,
label=f'Base Case: {base_value:.0f}')
ax.axvline(x=mc_results['enterprise_value'].median(), color='#00c9a7',
linestyle='-', linewidth=2, label=f'Median: {mc_results["enterprise_value"].median():.0f}')
ax.set_xlabel('Enterprise Value')
ax.set_ylabel('Frequency')
ax.set_title('Distribution of Simulated Enterprise Values', fontweight='bold')
ax.legend()
# Chart 2: Cumulative Distribution Function (CDF)
ax = axes[0, 1]
sorted_values = np.sort(mc_results['enterprise_value'])
cdf = np.arange(1, len(sorted_values) + 1) / len(sorted_values)
ax.plot(sorted_values, cdf, color='#6c8cff', linewidth=2.5)
ax.fill_between(sorted_values, 0, cdf, alpha=0.15, color='#6c8cff')
# Highlight percentiles
for pct, color in [(10, '#e0556a'), (50, '#6c8cff'), (90, '#00c9a7')]:
val = np.percentile(sorted_values, pct)
ax.axvline(x=val, color=color, linestyle='--', alpha=0.7, linewidth=1.5)
ax.axhline(y=pct/100, color=color, linestyle='--', alpha=0.7, linewidth=1.5)
ax.annotate(f'P{pct}: {val:.0f}', (val, pct/100),
textcoords="offset points", xytext=(10, -10), fontsize=9, color=color)
ax.set_xlabel('Enterprise Value')
ax.set_ylabel('Cumulative Probability')
ax.set_title('CDF — "What Is the Probability Value Exceeds X?"', fontweight='bold')
# Chart 3: Revenue Growth vs Operating Margin scatter (colored by value)
ax = axes[1, 0]
sample = mc_results.sample(2000) # Subset for clarity
sc = ax.scatter(sample['rev_growth'], sample['op_margin'],
c=sample['enterprise_value'], cmap='RdYlGn',
alpha=0.4, s=10, edgecolors='none')
ax.set_xlabel('Revenue Growth (%)')
ax.set_ylabel('Operating Margin (%)')
ax.set_title('Input Space — Which Combinations Produce High Value?', fontweight='bold')
plt.colorbar(sc, ax=ax, label='Enterprise Value')
# Chart 4: Box plot by Revenue Growth quartile
ax = axes[1, 1]
mc_results['growth_quartile'] = pd.qcut(mc_results['rev_growth'], q=4,
labels=['Q1 (Low)', 'Q2', 'Q3', 'Q4 (High)'])
mc_results.boxplot(column='enterprise_value', by='growth_quartile',
ax=ax, patch_artist=True,
boxprops=dict(facecolor='#6c8cff', alpha=0.6))
ax.set_xlabel('Revenue Growth Quartile')
ax.set_ylabel('Enterprise Value')
ax.set_title('How Revenue Growth Drives Valuation Spread', fontweight='bold')
fig.suptitle('')
plt.suptitle('Monte Carlo Valuation Simulation — 10,000 Trials',
fontsize=15, fontweight='bold', y=1.01)
plt.tight_layout()
plt.show()
plot_monte_carlo_results(mc_results, base_value=1000)
8.7 Interpreting Monte Carlo Results for Investment Decisions
The Monte Carlo output is not just a pretty histogram. It directly informs investment decisions:
8.7.1 Key Questions Monte Carlo Answers
| Question | How to Answer from Monte Carlo Results |
|---|---|
| What is the stock worth? | Report the median and interquartile range (P25–P75), not just a point estimate. "Our analysis suggests a median intrinsic value of Rs. 1,050, with a 50% probability the value lies between Rs. 920 and Rs. 1,210." |
| Is the stock overvalued or undervalued? | Compare the current market price to the distribution. If the market price is at the 15th percentile of the simulated distribution, the stock is likely undervalued. If it is at the 85th percentile, it is likely overvalued. |
| What is the probability of loss? | If the current market price is Rs. 950 and 35% of simulated values fall below Rs. 950, there is a ~35% probability that the stock is overvalued at the current price. This is your margin of safety analysis. |
| What drives the uncertainty? | Run a regression of simulated enterprise values against the input variables. The variable with the highest coefficient (in absolute terms) is the primary driver of valuation uncertainty. This confirms or challenges the tornado chart findings. |
| How fat are the tails? | Skewness and kurtosis tell you whether the distribution has a long downside tail (negative skew — more risk of extreme losses) or a long upside tail (positive skew — lottery-ticket characteristics). |
8.7.2 Regression on Simulation Inputs
from sklearn.linear_model import LinearRegression
# Regress enterprise value on input assumptions to rank drivers
X = mc_results[['rev_growth', 'op_margin', 'wacc']]
y = mc_results['enterprise_value']
lr = LinearRegression().fit(X, y)
# Standardize coefficients for comparability
from scipy import stats
X_std = X.apply(stats.zscore)
lr_std = LinearRegression().fit(X_std, y)
driver_importance = pd.DataFrame({
'Driver': X.columns,
'Raw Coefficient': lr.coef_,
'Standardized Impact': np.abs(lr_std.coef_)
}).sort_values('Standardized Impact', ascending=False)
print("=== Monte Carlo Driver Importance ===")
print("(Higher standardized impact = larger effect on valuation)\n")
print(driver_importance.to_string(index=False))
# Visualize
fig, ax = plt.subplots(figsize=(10, 5))
colors = ['#6c8cff' if c > 0 else '#e0556a' for c in lr.coef_]
ax.barh(driver_importance['Driver'], driver_importance['Standardized Impact'],
color=colors, edgecolor='white', linewidth=1)
ax.set_xlabel('Standardized Impact on Enterprise Value')
ax.set_title('What Drives Valuation Uncertainty? (Monte Carlo Regression)',
fontweight='bold')
ax.invert_yaxis()
plt.tight_layout()
plt.show()
8.8 Communicating Uncertainty to Stakeholders
The greatest challenge in scenario analysis is not technical — it is communication. Stakeholders (investment committees, clients, senior management) often want "the number," not a probability distribution. Your job is to convey uncertainty without undermining confidence in your analysis.
8.8.1 The Valuation Range Table
The most effective format for presenting scenario results is a concise table:
| Bear Case | Base Case | Bull Case | Monte Carlo (Median) | |
|---|---|---|---|---|
| Probability | 25% | 55% | 20% | N/A (distribution) |
| Revenue CAGR | 8.4% | 12.0% | 15.6% | 11.8% |
| Op Margin (Y5) | 15.8% | 20.0% | 23.0% | 19.7% |
| WACC | 10.5% | 10.0% | 9.7% | 10.0% |
| Enterprise Value | Rs. 680 Cr | Rs. 1,000 Cr | Rs. 1,350 Cr | Rs. 1,020 Cr |
| vs Current Price | −32% (overvalued) | Fair value | +35% (undervalued) | +2% (fair) |
8.8.2 Best Practices for Presenting Uncertainty
- Never say "the company is worth Rs. 847.32." Say "our analysis suggests a median intrinsic value of approximately Rs. 850, with a plausible range of Rs. 680–1,050 based on our scenario analysis."
- Show the distribution, not just the range. A fan chart or histogram communicates uncertainty far better than a "Rs. 680–1,350" range, which sounds like a guess.
- Explain the top 3 drivers. "This valuation is most sensitive to revenue growth assumptions. A 1% change in the growth rate moves the value by approximately 8%. Our 12% growth assumption is based on..."
- Acknowledge what you don't know. "Our Monte Carlo simulation assigns a 30% probability that the current market price overstates intrinsic value. The primary risk factors are a cyclical downturn in the company's end-markets and rising raw material costs."
Hands-On Project: Complete Uncertainty Analysis for Your Capstone Company
Take the 5-year forecast you built in Chapter 7 and subject it to a complete uncertainty analysis — three scenarios, tornado chart, two-way sensitivity heatmaps, and a Monte Carlo simulation with 10,000 trials. Produce a one-page "Uncertainty Dashboard" that communicates the range of possible outcomes and their drivers.
Steps
- Build three scenarios (Bear, Base, Bull) for your capstone company. Define explicit, internally consistent assumptions for each. Assign probabilities.
- Compute probability-weighted expected value and compare it to the base case. Is the distribution skewed (upside potential exceeds downside risk, or vice versa)?
- Build a tornado chart by varying each of 6–8 key assumptions across ±20–30% of their base values. Identify the top 3 value drivers.
- Create two heatmaps: Revenue Growth vs Operating Margin, and Revenue Growth vs WACC.
- Run a Monte Carlo simulation with at least 5,000 trials. Use the simplified model from Section 8.6 or plug the random draws into your own DCF function.
- Analyze the Monte Carlo results: What percentile is the current market price? What is the probability the stock is overvalued? Which input drives the most uncertainty?
- Assemble a one-page "Uncertainty Dashboard" containing: the scenario summary table, the tornado chart, the Revenue Growth × Op Margin heatmap, and the Monte Carlo histogram. This is what you would present to an investment committee.
View Solution / Walkthrough
Complete Uncertainty Dashboard — Illustrative Output
# ================================================================
# COMPLETE UNCERTAINTY ANALYSIS PIPELINE
# ================================================================
# --- 1. Three Scenarios ---
scenarios = build_three_scenarios(
base_revenue_growth=0.12,
base_op_margin=20.0,
base_wacc=0.10
)
print("=" * 65)
print(" UNCERTAINTY ANALYSIS — ASIAN PAINTS (Illustrative)")
print("=" * 65)
# Scenario summary
print("\n--- SCENARIO SUMMARY ---")
scenario_data = []
for name, s in scenarios.items():
scenario_data.append({
'Scenario': name,
'Probability': f"{s['Probability']*100:.0f}%",
'Rev Growth': f"{s['Revenue Growth %']:.1f}%",
'Op Margin': f"{s['Op Margin %']:.1f}%",
'WACC': f"{s['WACC %']:.1f}%",
'Year 5 Revenue': f"Rs. {s['Year5 Revenue']:.0f}",
'Approx Value': f"Rs. {s['Approx PV']:.0f}"
})
scenario_df = pd.DataFrame(scenario_data)
print(scenario_df.to_string(index=False))
expected_value = sum(s['Approx PV'] * s['Probability'] for s in scenarios.values())
base_value = scenarios['Base']['Approx PV']
print(f"\nExpected Value: Rs. {expected_value:.0f}")
print(f"Base Case: Rs. {base_value:.0f}")
print(f"Skew: Rs. {expected_value - base_value:+.0f} "
f"({'upside-dominated' if expected_value > base_value else 'downside-dominated'})")
# --- 2. Tornado Chart ---
print("\n--- TORNADO: TOP VALUE DRIVERS ---")
tornado = tornado_chart(base_assumptions, sensitivities, base_value)
print(tornado[['Assumption', 'Range']].head(5).to_string(index=False))
# --- 3. Two-Way Heatmaps ---
print("\n--- TWO-WAY SENSITIVITY ---")
matrix = two_way_sensitivity(
'Revenue Growth', rev_growth_values,
'Operating Margin', op_margin_values,
base_assumptions, base_value
)
plot_two_way_heatmap(matrix,
'Revenue Growth', rev_growth_values,
'Operating Margin', op_margin_values, base_value)
# --- 4. Monte Carlo ---
print("\n--- MONTE CARLO (10,000 trials) ---")
mc_results = monte_carlo_valuation(n_simulations=10000)
p10 = np.percentile(mc_results['enterprise_value'], 10)
p50 = np.percentile(mc_results['enterprise_value'], 50)
p90 = np.percentile(mc_results['enterprise_value'], 90)
print(f" P10: Rs. {p10:.0f}")
print(f" P50: Rs. {p50:.0f}")
print(f" P90: Rs. {p90:.0f}")
print(f" Mean: Rs. {mc_results['enterprise_value'].mean():.0f}")
current_price = 950 # Hypothetical current market price proxy
percentile = (mc_results['enterprise_value'] < current_price).mean() * 100
print(f"\n Current Price Proxy: Rs. {current_price}")
print(f" Price is at the ~{percentile:.0f}th percentile of simulated values")
print(f" P(Overvalued): ~{percentile:.0f}% | P(Undervalued): ~{100-percentile:.0f}%")
plot_monte_carlo_results(mc_results, base_value)
# --- 5. Final Dashboard Output ---
print("\n" + "=" * 65)
print(" INVESTMENT CONCLUSION")
print("=" * 65)
print(f"""
Based on a base case revenue CAGR of 12% and operating margin of 20%,
supplemented by scenario analysis and Monte Carlo simulation:
• Median Intrinsic Value: Rs. {p50:.0f}
• Plausible Range (P10–P90): Rs. {p10:.0f} – Rs. {p90:.0f}
• vs Current Price ({current_price}): {'Undervalued' if p50 > current_price else 'Overvalued'}
• Probability of Overvaluation: {percentile:.0f}%
TOP 3 VALUE DRIVERS (from tornado):
1. Revenue Growth Rate
2. Operating Margin
3. WACC
KEY RISK: A 200bp slowdown in revenue growth reduces intrinsic value
by ~15%. The primary monitorable is the company's quarterly revenue
trajectory vs our 12% CAGR assumption.
""")
Interpretation Guide:
- Scenario Fan Chart: Shows how the range of possible outcomes widens over time. By Year 5, revenue could be anywhere from 60% to 180% of the base case. This is realistic — most forecasts are wrong by Year 5, and the fan chart quantifies how wrong.
- Tornado Chart: Typically shows Revenue Growth as the #1 driver, followed by Operating Margin and WACC. If any other variable (capex, working capital) appears in the top 3, investigate — it may indicate unusual capital intensity or working capital dynamics.
- Monte Carlo Histogram: A normal-looking distribution centered near the base case suggests balanced risks. A distribution with a long left tail (negative skew) suggests asymmetric downside — common in highly leveraged or cyclical companies. A long right tail suggests optionality — common in growth companies and startups.
Key Takeaways
Single-point forecasts are necessary but insufficient. Every DCF valuation must be accompanied by scenarios and sensitivity analysis that quantify the range of plausible outcomes.
The tornado chart identifies your model's critical assumptions. 2–3 variables typically drive 70–80% of valuation uncertainty. Focus your research and defense on these.
Monte Carlo simulation transforms assumptions into probabilities. Instead of "the stock is worth Rs. 1,000," you can say "there is a 65% probability the stock is undervalued at the current price."
The two-way sensitivity heatmap reveals interaction effects. It shows whether growth matters more when margins are high or low — insights invisible to one-way sensitivity.
Uncertainty is not a weakness of your analysis. Acknowledging the range of possible outcomes and quantifying it is a sign of analytical rigor, not indecision. The investment committee respects honesty more than false precision.
Test Your Understanding
1. What is the primary purpose of scenario analysis in corporate valuation?
2. A tornado chart in sensitivity analysis shows:
3. How does Monte Carlo simulation differ from the three-scenario (Base/Bull/Bear) approach?
4. A Monte Carlo simulation produces a negatively skewed distribution of enterprise values (long left tail). What does this indicate?
5. In a two-way sensitivity heatmap of Revenue Growth vs Operating Margin, the top-right corner shows the highest valuation. The gradient is predominantly vertical (changes faster moving up-down than left-right). What does this tell you?