Session 8 of 26 Part III: Forecasting — Chapter 8 of 18
Part III: Forecasting Chapter 8 of 18 Session 8 Python

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

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:

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.

🌎
Real World: In early 2020, any DCF model of an airline or hospitality company based on a "most likely" scenario would have been worthless within weeks. But a model that included a bear case with a 70% revenue decline for 2 years — even if assigned only a 5% probability — would have provided decision-makers with a framework for understanding the downside. Scenario analysis is not about predicting black swans; it is about understanding how much can go wrong and what drives that fragility.

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.

ComponentBase CaseBull CaseBear Case
Probability50–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 GrowthHistorical average, adjusted for industry outlookAbove trend — market share gains, new product success, favorable macroBelow trend or negative — demand destruction, market share loss
MarginsStable at historical levelsExpanding — operating leverage, pricing power, cost reductionContracting — price cuts, cost inflation, negative operating leverage
WACCCurrent estimated WACCLower — falling interest rates, lower equity risk premiumHigher — rising rates, higher risk premium, credit spread widening
Capex / InvestmentMaintenance + planned growthHigher investment — funding growth opportunitiesCut to maintenance only — cash preservation
📝
Note: The base case is NOT the average of bull and bear. It is your genuine best estimate of the most likely outcome. If you find yourself setting the base case as the midpoint between bull and bear, you are not thinking independently about the scenarios — you are anchoring on extremes.

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))
💡
Pro Tip: The tornado chart typically reveals that 2–3 assumptions account for 70–80% of the valuation uncertainty. In most DCF models, revenue growth, operating margin, and WACC are the dominant drivers. Focus your research time on validating these, not on fine-tuning the depreciation schedule. When defending your valuation, be prepared to answer detailed questions about the top 3 bars on the tornado chart.

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)
📝
Reading the Heatmap: In the Revenue Growth vs Operating Margin heatmap, the top-right corner (high growth + high margin) should show the highest valuation. The bottom-left (low growth + low margin) shows the lowest. The gradient reveals whether value is more sensitive to growth (vertical gradient dominates) or margins (horizontal gradient dominates). This tells you which metric deserves more analytical rigor.

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

  • Define probability distributions for each key assumption. Revenue growth might be normally distributed with a mean of 12% and standard deviation of 3%. WACC might follow a triangular distribution between 8% and 13%, most likely at 10%.
  • Run one trial: Draw a random value from each distribution. Plug all the values into the valuation model. Record the resulting enterprise value. This is one possible future.
  • Repeat: Do this 5,000–50,000 times. You now have a distribution of possible valuations — not just a point estimate.
  • Analyze the distribution: What is the mean? The median? The 10th percentile (downside)? The 90th percentile (upside)? What is the probability that the company is worth less than its current market price?
  • 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)
    Important: The Monte Carlo simulation above uses a simplified valuation model for illustration. In practice, you should plug the random draws into your full DCF model from Chapter 14. The principle remains identical — the only difference is the complexity of the valuation function that sits inside the simulation loop.

    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

    QuestionHow 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 CaseBase CaseBull CaseMonte Carlo (Median)
    Probability25%55%20%N/A (distribution)
    Revenue CAGR8.4%12.0%15.6%11.8%
    Op Margin (Y5)15.8%20.0%23.0%19.7%
    WACC10.5%10.0%9.7%10.0%
    Enterprise ValueRs. 680 CrRs. 1,000 CrRs. 1,350 CrRs. 1,020 Cr
    vs Current Price−32% (overvalued)Fair value+35% (undervalued)+2% (fair)

    8.8.2 Best Practices for Presenting Uncertainty

    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

    1. Build three scenarios (Bear, Base, Bull) for your capstone company. Define explicit, internally consistent assumptions for each. Assign probabilities.
    2. Compute probability-weighted expected value and compare it to the base case. Is the distribution skewed (upside potential exceeds downside risk, or vice versa)?
    3. 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.
    4. Create two heatmaps: Revenue Growth vs Operating Margin, and Revenue Growth vs WACC.
    5. 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.
    6. 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?
    7. 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

    1

    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.

    2

    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.

    3

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

    4

    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.

    5

    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?