Sessions 14–15 of 26 Part V: DCF Valuation — Chapter 14 of 18
Part V: DCF Valuation Chapter 14 of 18 Sessions 14–15 Python

Complete DCF Model & Sensitivity Analysis

Assemble the end-to-end DCF valuation engine, build tornado and waterfall charts, and stress-test every assumption to produce a defensible, boardroom-ready valuation.

Learning Objectives

Session 14 — Complete DCF Model (Part 1)

14.1 The Complete DCF Class

Chapters 7 through 13 built the DCF in pieces. Now we assemble everything into a single, robust DCFValuation class. This class takes historical data and assumptions, runs the integrated model, computes WACC, discounts FCFF, estimates terminal value, and produces the full valuation output — all in one call.

import numpy as np
import pandas as pd
import yfinance as yf
from scipy import stats
import matplotlib.pyplot as plt
import seaborn as sns
import warnings
warnings.filterwarnings('ignore')

sns.set_style("darkgrid")
plt.rcParams['figure.figsize'] = (14, 6)


class DCFValuation:
    """
    Complete end-to-end DCF valuation model for an Indian company.

    Usage:
        dcf = DCFValuation('ASIANPAINT.NS')
        dcf.run()
        dcf.summary()
        dcf.plot_waterfall()
        dcf.tornado_chart()
    """

    def __init__(self, ticker_symbol,
                 rf=0.0675, us_erp=0.05, india_crp=0.0225,
                 tax_rate=0.25, terminal_g=0.035,
                 forecast_years=5, mid_year=True):
        """
        Initialize the DCF valuation model.

        Parameters
        ----------
        ticker_symbol : str — NSE ticker (e.g., 'ASIANPAINT.NS')
        rf : float — risk-free rate
        us_erp : float — US implied equity risk premium
        india_crp : float — India country risk premium
        tax_rate : float — marginal tax rate
        terminal_g : float — perpetual growth rate
        forecast_years : int — explicit forecast period
        mid_year : bool — use mid-year discounting convention
        """
        self.ticker = ticker_symbol
        self.rf = rf
        self.us_erp = us_erp
        self.india_crp = india_crp
        self.india_erp = us_erp + india_crp
        self.tax_rate = tax_rate
        self.terminal_g = terminal_g
        self.forecast_years = forecast_years
        self.mid_year = mid_year

        # These will be populated by .run()
        self.info = None
        self.historical = None
        self.forecast = None
        self.wacc = None
        self.ke = None
        self.fcff_forecast = None
        self.pv_explicit = None
        self.pv_terminal = None
        self.enterprise_value = None
        self.equity_value = None
        self.per_share_value = None

    # ================================================================
    # DATA FETCHING
    # ================================================================
    def _safe_get(self, df, candidates):
        for c in ([candidates] if isinstance(candidates, str) else candidates):
            if c in df.index:
                return df.loc[c]
        return None

    def _fetch_data(self):
        """Fetch financial data from yfinance."""
        ticker = yf.Ticker(self.ticker)
        self.info = ticker.info

        is_df = ticker.financials.sort_index(axis=1)
        bs_df = ticker.balance_sheet.sort_index(axis=1)
        cf_df = ticker.cashflow.sort_index(axis=1)

        # Latest year data
        rev = self._safe_get(is_df, ['Total Revenue', 'Revenue']).iloc[0] / 1e7
        cogs = self._safe_get(is_df, ['Cost Of Goods Sold', 'Cost of Revenue'])
        cogs = cogs.iloc[0] / 1e7 if cogs is not None else rev * 0.55
        ebit = self._safe_get(is_df, ['Operating Income', 'EBIT']).iloc[0] / 1e7
        ni = self._safe_get(is_df, ['Net Income']).iloc[0] / 1e7
        depr = self._safe_get(is_df, ['Depreciation And Amortization'])
        depr = depr.iloc[0] / 1e7 if depr is not None else rev * 0.03
        interest = self._safe_get(is_df, ['Interest Expense'])
        interest = interest.iloc[0] / 1e7 if interest is not None else rev * 0.01

        cash = self._safe_get(bs_df, ['Cash And Cash Equivalents']).iloc[0] / 1e7
        ar = self._safe_get(bs_df, ['Accounts Receivable', 'Net Receivables'])
        ar = ar.iloc[0] / 1e7 if ar is not None else rev * 0.15
        inv = self._safe_get(bs_df, ['Inventory']).iloc[0] / 1e7
        nppe = self._safe_get(bs_df, ['Net PPE']).iloc[0] / 1e7
        ap = self._safe_get(bs_df, ['Accounts Payable']).iloc[0] / 1e7
        debt = self._safe_get(bs_df, ['Total Debt', 'Long Term Debt'])
        debt = debt.iloc[0] / 1e7 if debt is not None else 0
        equity_bs = self._safe_get(bs_df, ['Total Equity Gross Minority Interest',
                                            'Stockholders Equity']).iloc[0] / 1e7
        minority = self._safe_get(bs_df, ['Minority Interest'])
        minority = minority.iloc[0] / 1e7 if minority is not None else 0

        ocf = self._safe_get(cf_df, ['Operating Cash Flow'])
        ocf = ocf.iloc[0] / 1e7 if ocf is not None else None
        capex_cf = self._safe_get(cf_df, ['Capital Expenditure'])
        capex_cf = capex_cf.iloc[0] / 1e7 if capex_cf is not None else None

        self.historical = {
            'Revenue': rev, 'COGS': cogs, 'EBIT': ebit, 'NetIncome': ni,
            'Depreciation': depr, 'Interest': interest,
            'Cash': cash, 'Receivables': ar, 'Inventory': inv,
            'NetPPE': nppe, 'Payables': ap, 'TotalDebt': debt,
            'Equity': equity_bs, 'MinorityInterest': minority,
            'OperatingCF': ocf, 'Capex': capex_cf,
        }

    # ================================================================
    # WACC COMPUTATION
    # ================================================================
    def _compute_wacc(self):
        """Compute WACC using CAPM and credit spread approach."""
        # Beta
        stock = yf.download(self.ticker, period='5y', progress=False)['Adj Close']
        market = yf.download('^NSEI', period='5y', progress=False)['Adj Close']
        sr = stock.resample('ME').last().pct_change().dropna()
        mr = market.resample('ME').last().pct_change().dropna()
        common = sr.index.intersection(mr.index)
        rf_m = (1 + self.rf)**(1/12) - 1
        raw_beta, _, _, _, _ = stats.linregress(
            mr[common] - rf_m, sr[common] - rf_m)
        self.adj_beta = (2/3) * raw_beta + (1/3)

        self.ke = self.rf + self.adj_beta * self.india_erp

        # Cost of debt (synthetic rating)
        h = self.historical
        icr = h['EBIT'] / h['Interest'] if h['Interest'] > 0 else 10
        icr_map = [(8.5, 0.0065), (6.5, 0.01), (4.5, 0.018),
                   (3.0, 0.03), (2.0, 0.045), (1.5, 0.06), (0, 0.10)]
        spread = next(s for min_icr, s in icr_map if icr >= min_icr)
        self.kd_pre = self.rf + spread
        self.kd_post = self.kd_pre * (1 - self.tax_rate)

        # Weights
        mkt_cap = self.info.get('marketCap', 1e12) / 1e7
        net_debt = max(h['TotalDebt'] - h['Cash'], 0)
        ev_weight = mkt_cap + net_debt
        e_w = mkt_cap / ev_weight
        d_w = net_debt / ev_weight

        self.wacc = e_w * self.ke + d_w * self.kd_post
        self.mkt_cap = mkt_cap
        self.net_debt = net_debt
        self.equity_weight = e_w
        self.debt_weight = d_w

    # ================================================================
    # FORECAST & FCFF
    # ================================================================
    def _forecast_and_fcff(self):
        """Build the integrated forecast and compute FCFF."""
        h = self.historical

        # Assumptions derived from historical data
        op_margin = max(h['EBIT'] / h['Revenue'], 0.08)
        depr_rate = h['Depreciation'] / h['Revenue']
        capex_pct = abs(h['Capex']) / h['Revenue'] if h['Capex'] else 0.06
        wc_pct = ((h['Receivables'] + h['Inventory'] - h['Payables'])
                   / h['Revenue'])

        # Growth rates (decelerating)
        hist_growth = 0.12  # Default; ideally from Propht/ARIMA (Ch7)
        growth_rates = [hist_growth * (0.95**i) for i in range(self.forecast_years)]

        forecast_rows = []
        fcff_list = []
        prev_rev = h['Revenue']
        prev_wc = h['Receivables'] + h['Inventory'] - h['Payables']

        for i, g in enumerate(growth_rates):
            rev = prev_rev * (1 + g)
            ebit = rev * op_margin
            nopat = ebit * (1 - self.tax_rate)
            depr = rev * depr_rate
            capex = rev * capex_pct
            wc = rev * wc_pct
            delta_wc = wc - prev_wc
            fcff = nopat + depr - capex - delta_wc

            forecast_rows.append({
                'Year': i + 1, 'Revenue': rev, 'Growth': g,
                'EBIT': ebit, 'NOPAT': nopat, 'Depreciation': depr,
                'Capex': capex, 'DeltaWC': delta_wc, 'FCFF': fcff,
                'EBITDA': ebit + depr,
            })
            fcff_list.append(fcff)
            prev_rev = rev
            prev_wc = wc

        self.forecast = pd.DataFrame(forecast_rows).set_index('Year')
        self.fcff_forecast = np.array(fcff_list)

    # ================================================================
    # DCF VALUATION
    # ================================================================
    def _compute_dcf(self):
        """Discount FCFF, compute terminal value, derive equity value."""
        n = self.forecast_years
        fcff = self.fcff_forecast

        # PV of explicit forecast
        pvs = []
        for t, f in enumerate(fcff, 1):
            exponent = t - 0.5 if self.mid_year else t
            pv = f / (1 + self.wacc) ** exponent
            pvs.append(pv)
        self.pv_explicit = sum(pvs)

        # Terminal value
        fcff_n = fcff[-1]
        fcff_n1 = fcff_n * (1 + self.terminal_g)
        tv = fcff_n1 / (self.wacc - self.terminal_g)
        self.tv_terminal_year = tv
        self.pv_terminal = tv / (1 + self.wacc) ** n
        self.implied_exit_ebitda = tv / self.forecast['EBITDA'].iloc[-1]

        # Enterprise and Equity Value
        self.enterprise_value = self.pv_explicit + self.pv_terminal
        self.equity_value = (self.enterprise_value
                             - self.net_debt
                             - self.historical.get('MinorityInterest', 0))

        # Per share
        shares = self.info.get('sharesOutstanding')
        if not shares:
            shares = self.info.get('impliedSharesOutstanding')
        self.shares_outstanding = shares / 1e7 if shares else 100  # crores
        self.per_share_value = self.equity_value / self.shares_outstanding
        self.market_price = (self.info.get('currentPrice') or
                             self.info.get('previousClose', 0))

    # ================================================================
    # RUN
    # ================================================================
    def run(self):
        """Execute the complete DCF valuation."""
        print(f"Running DCF Valuation for {self.ticker}...")
        self._fetch_data()
        print(f"  ✓ Data fetched: {self.info.get('longName', self.ticker)}")
        self._compute_wacc()
        print(f"  ✓ WACC: {self.wacc*100:.2f}% (Ke={self.ke*100:.2f}%, "
              f"β={self.adj_beta:.2f})")
        self._forecast_and_fcff()
        print(f"  ✓ FCFF forecast complete")
        self._compute_dcf()
        print(f"  ✓ DCF complete: EV=Rs.{self.enterprise_value:,.0f}Cr, "
              f"IV/Share=Rs.{self.per_share_value:,.0f}")
        return self

    # ================================================================
    # SUMMARY
    # ================================================================
    def summary(self):
        """Print a complete DCF valuation summary."""
        tv_pct = self.pv_terminal / self.enterprise_value * 100
        upside = (self.per_share_value / self.market_price - 1) * 100 if self.market_price else 0

        print(f"\n{'='*65}")
        print(f"  DCF VALUATION: {self.info.get('longName', self.ticker)}")
        print(f"{'='*65}")
        print(f"\n  KEY INPUTS:")
        print(f"    Rf={self.rf*100:.2f}% | ERP={self.india_erp*100:.2f}% | "
              f"β={self.adj_beta:.2f} | g={self.terminal_g*100:.1f}%")
        print(f"    Ke={self.ke*100:.2f}% | Kd={self.kd_post*100:.2f}% | "
              f"WACC={self.wacc*100:.2f}%")
        print(f"\n  FCFF FORECAST (Rs. Cr):")
        for yr in self.forecast.index:
            row = self.forecast.loc[yr]
            print(f"    Y{yr}: Rev={row['Revenue']:,.0f} | "
                  f"EBIT={row['EBIT']:,.0f} | FCFF={row['FCFF']:,.0f}")
        print(f"\n  VALUATION (Rs. Cr):")
        print(f"    PV Explicit FCFF:  {self.pv_explicit:>12,.0f}  "
              f"({100-tv_pct:.0f}%)")
        print(f"    PV Terminal Value: {self.pv_terminal:>12,.0f}  "
              f"({tv_pct:.0f}%)")
        print(f"    ─────────────────────────────")
        print(f"    Enterprise Value:  {self.enterprise_value:>12,.0f}")
        print(f"    (−) Net Debt:      {self.net_debt:>12,.0f}")
        print(f"    ─────────────────────────────")
        print(f"    Equity Value:      {self.equity_value:>12,.0f}")
        print(f"\n    Shares (Cr):       {self.shares_outstanding:>12.1f}")
        print(f"    INTRINSIC VALUE:   Rs. {self.per_share_value:>10,.0f}")
        print(f"    Market Price:      Rs. {self.market_price:>10,.0f}")
        print(f"    Upside/(Downside): {upside:>+11.1f}%")
        print(f"\n    RECOMMENDATION:    {'BUY' if upside > 15 else ('HOLD' if upside > -10 else 'SELL')}")
        print(f"{'='*65}")

    # ================================================================
    # TORNADO CHART
    # ================================================================
    def tornado(self):
        """
        One-way sensitivity tornado chart.
        Varies each key assumption by ±20% and measures impact on per-share value.
        """
        base_iv = self.per_share_value

        # Parameters to test with their base values and ranges
        params = {
            'Revenue Growth': (0.12, 0.08, 0.16),
            'Operating Margin': (self.forecast['EBIT'].iloc[0] / self.forecast['Revenue'].iloc[0],
                                 0.15, 0.30),
            'WACC': (self.wacc, self.wacc * 0.85, self.wacc * 1.15),
            'Terminal Growth': (self.terminal_g, 0.02, 0.05),
            'Capex / Revenue': (abs(self.forecast['Capex'].iloc[0]) / self.forecast['Revenue'].iloc[0],
                                0.03, 0.09),
            'Tax Rate': (self.tax_rate, 0.20, 0.30),
        }

        # Quick revaluation function
        def quick_reval(**overrides):
            """Recompute IV with overridden parameters. Simplified for speed."""
            g_list = [overrides.get('Revenue Growth', 0.12) * (0.95**i)
                      for i in range(self.forecast_years)]
            op_m = overrides.get('Operating Margin', 0.20)
            w = overrides.get('WACC', self.wacc)
            tg = overrides.get('Terminal Growth', self.terminal_g)
            cp = overrides.get('Capex / Revenue', 0.06)
            tr = overrides.get('Tax Rate', self.tax_rate)
            depr_r = 0.03
            wc_p = 0.15

            prev_rev = self.historical['Revenue']
            prev_wc = prev_rev * 0.12
            fcffs = []
            for g in g_list:
                rev = prev_rev * (1 + g)
                nopat = rev * op_m * (1 - tr)
                de = rev * depr_r
                cx = rev * cp
                dwc = rev * wc_p - prev_wc
                fcffs.append(nopat + de - cx - dwc)
                prev_rev = rev
                prev_wc = rev * wc_p

            pv_e = sum(f / (1 + w) ** (t + 0.5) for t, f in enumerate(fcffs, 1))
            tv = fcffs[-1] * (1 + tg) / (w - tg)
            pv_t = tv / (1 + w) ** self.forecast_years
            return (pv_e + pv_t - self.net_debt) / self.shares_outstanding

        # Compute impacts
        impacts = []
        for name, (base, low, high) in params.items():
            iv_low = quick_reval(**{name: low})
            iv_high = quick_reval(**{name: high})
            impacts.append({
                'Parameter': name,
                'Downside': iv_low - base_iv,
                'Upside': iv_high - base_iv,
                'Range': abs(iv_high - iv_low)
            })

        tornado_df = pd.DataFrame(impacts).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['Upside'], left=base_iv,
                height=0.5, color='#00c9a7', alpha=0.85, label='Upside (+20%)')
        ax.barh(y_pos, tornado_df['Downside'], left=base_iv,
                height=0.5, color='#e0556a', alpha=0.85, label='Downside (−20%)')

        ax.set_yticks(y_pos)
        ax.set_yticklabels(tornado_df['Parameter'], fontsize=11)
        ax.axvline(x=base_iv, color='#6c8cff', linewidth=2.5, linestyle='--',
                   label=f'Base IV: Rs. {base_iv:,.0f}')
        ax.set_xlabel('Intrinsic Value Per Share (Rs.)', fontsize=11)
        ax.set_title('Tornado Chart — Sensitivity of Per-Share Value to Key Assumptions',
                     fontweight='bold', fontsize=14)
        ax.legend(fontsize=10)
        ax.grid(True, alpha=0.2, axis='x')
        plt.tight_layout()
        plt.show()

        return tornado_df

    # ================================================================
    # VALUATION WATERFALL
    # ================================================================
    def waterfall(self):
        """
        Build a valuation waterfall chart showing how each component
        contributes from Revenue to Equity Value per share.
        """
        fcffs = self.fcff_forecast

        # Build the waterfall steps
        steps = [
            ('Year 1 FCFF', fcffs[0] / self.shares_outstanding / (1 + self.wacc) ** 0.5),
            ('Year 2 FCFF', fcffs[1] / self.shares_outstanding / (1 + self.wacc) ** 1.5),
            ('Year 3 FCFF', fcffs[2] / self.shares_outstanding / (1 + self.wacc) ** 2.5),
            ('Year 4 FCFF', fcffs[3] / self.shares_outstanding / (1 + self.wacc) ** 3.5),
            ('Year 5 FCFF', fcffs[4] / self.shares_outstanding / (1 + self.wacc) ** 4.5),
            ('Terminal Value',
             self.pv_terminal / self.shares_outstanding),
            ('(−) Net Debt',
             -self.net_debt / self.shares_outstanding),
        ]

        labels = [s[0] for s in steps]
        values = [s[1] for s in steps]

        # Waterfall construction
        bottoms = []
        running_total = 0
        for v in values:
            if v >= 0:
                bottoms.append(running_total)
            else:
                bottoms.append(running_total + v)
            running_total += v

        fig, ax = plt.subplots(figsize=(14, 7))
        colors = ['#6c8cff'] * 5 + ['#00c9a7'] + ['#e0556a']
        bars = ax.bar(labels, values, bottom=bottoms, color=colors,
                      edgecolor='white', linewidth=1.5)

        # Connect bars
        connectors = [0] + list(np.cumsum(values))
        for i in range(len(connectors) - 1):
            ax.plot([i + 0.4, i + 1 - 0.4],
                    [connectors[i + 1]] * 2,
                    'k-', linewidth=1, color='gray', alpha=0.5)

        # Annotate each bar
        for bar, val in zip(bars, values):
            height = bar.get_height()
            y_pos = bar.get_y() + height / 2
            ax.text(bar.get_x() + bar.get_width() / 2, y_pos,
                    f'Rs.{val:,.0f}',
                    ha='center', va='center', fontsize=9, fontweight='bold',
                    color='white' if abs(val) > max(values) * 0.05 else 'black')

        # Final value
        ax.scatter([len(labels) - 0.2], [running_total], s=300, color='#6c8cff',
                   zorder=10, edgecolors='white', linewidth=2)
        ax.annotate(f'Intrinsic Value\nRs. {running_total:,.0f}/share',
                    (len(labels) - 0.2, running_total),
                    textcoords="offset points", xytext=(20, 20),
                    fontsize=12, fontweight='bold', color='#6c8cff',
                    arrowprops=dict(arrowstyle='->', color='#6c8cff', lw=2))

        ax.set_ylabel('Value Per Share (Rs.)', fontsize=11)
        ax.set_title('Valuation Waterfall — From FCFF to Intrinsic Value Per Share',
                     fontweight='bold', fontsize=14)
        ax.axhline(y=0, color='gray', linestyle='-', alpha=0.3)
        ax.grid(True, alpha=0.2, axis='y')
        plt.tight_layout()
        plt.show()

        return running_total

    # ================================================================
    # SENSITIVITY HEATMAPS
    # ================================================================
    def sensitivity_heatmaps(self):
        """
        Two key heatmaps:
        1. WACC vs Terminal Growth → IV/share
        2. Revenue Growth vs Operating Margin → IV/share
        """
        fig, axes = plt.subplots(1, 2, figsize=(18, 7))

        # Heatmap 1: WACC vs Terminal Growth
        wacc_range = np.linspace(self.wacc - 0.025, self.wacc + 0.025, 8)
        g_range = np.linspace(self.terminal_g - 0.015, self.terminal_g + 0.015, 8)

        grid1 = np.zeros((len(wacc_range), len(g_range)))
        fcffs = self.fcff_forecast
        for i, w in enumerate(wacc_range):
            for j, g in enumerate(g_range):
                pv_e = sum(f / (1 + w) ** (t + 0.5) for t, f in enumerate(fcffs, 1))
                tv = fcffs[-1] * (1 + g) / (w - g)
                pv_t = tv / (1 + w) ** self.forecast_years
                grid1[i, j] = (pv_e + pv_t - self.net_debt) / self.shares_outstanding

        sns.heatmap(grid1, annot=True, fmt='.0f', cmap='RdYlGn',
                    xticklabels=[f'{g*100:.1f}%' for g in g_range],
                    yticklabels=[f'{w*100:.1f}%' for w in wacc_range],
                    center=self.per_share_value, linewidths=0.5, ax=axes[0],
                    cbar_kws={'label': 'IV/Share (Rs.)'})
        axes[0].set_xlabel('Terminal Growth Rate', fontsize=10)
        axes[0].set_ylabel('WACC', fontsize=10)
        axes[0].set_title('WACC vs Terminal Growth', fontweight='bold', fontsize=12)

        # Highlight base case
        wacc_idx = np.argmin(np.abs(wacc_range - self.wacc))
        g_idx = np.argmin(np.abs(g_range - self.terminal_g))
        axes[0].add_patch(plt.Rectangle((g_idx, wacc_idx), 1, 1,
                          fill=False, edgecolor='#6c8cff', linewidth=3))

        # Heatmap 2: Revenue Growth vs Op Margin
        growth_range = np.linspace(0.06, 0.18, 8)
        margin_range = np.linspace(0.14, 0.28, 8)

        grid2 = np.zeros((len(growth_range), len(margin_range)))
        for i, gr in enumerate(growth_range):
            for j, om in enumerate(margin_range):
                g_list = [gr * (0.95**k) for k in range(self.forecast_years)]
                prev_rev = self.historical['Revenue']
                prev_wc = prev_rev * 0.12
                ffs = []
                for gv in g_list:
                    rev = prev_rev * (1 + gv)
                    nopat = rev * om * (1 - self.tax_rate)
                    de = rev * 0.03
                    cx = rev * 0.06
                    dwc = rev * 0.15 - prev_wc
                    ffs.append(nopat + de - cx - dwc)
                    prev_rev = rev
                    prev_wc = rev * 0.15
                pv_e = sum(f / (1 + self.wacc) ** (t + 0.5) for t, f in enumerate(ffs, 1))
                tv = ffs[-1] * (1 + self.terminal_g) / (self.wacc - self.terminal_g)
                pv_t = tv / (1 + self.wacc) ** self.forecast_years
                grid2[i, j] = (pv_e + pv_t - self.net_debt) / self.shares_outstanding

        sns.heatmap(grid2, annot=True, fmt='.0f', cmap='RdYlGn',
                    xticklabels=[f'{m*100:.0f}%' for m in margin_range],
                    yticklabels=[f'{g*100:.0f}%' for g in growth_range],
                    center=self.per_share_value, linewidths=0.5, ax=axes[1],
                    cbar_kws={'label': 'IV/Share (Rs.)'})
        axes[1].set_xlabel('Operating Margin', fontsize=10)
        axes[1].set_ylabel('Revenue Growth (Year 1)', fontsize=10)
        axes[1].set_title('Revenue Growth vs Operating Margin', fontweight='bold', fontsize=12)

        plt.suptitle('DCF Sensitivity Heatmaps', fontsize=15, fontweight='bold', y=1.02)
        plt.tight_layout()
        plt.show()

    # ================================================================
    # DASHBOARD
    # ================================================================
    def dashboard(self):
        """Produce a one-page DCF dashboard with all key exhibits."""
        fig = plt.figure(figsize=(20, 22))

        # --- 1. Title Panel ---
        ax_title = fig.add_axes([0.05, 0.93, 0.9, 0.05])
        ax_title.axis('off')
        company = self.info.get('longName', self.ticker)
        upside = (self.per_share_value / self.market_price - 1) * 100 if self.market_price else 0
        rec = 'BUY' if upside > 15 else ('HOLD' if upside > -10 else 'SELL')
        ax_title.text(0.5, 0.5,
                      f'{company} — DCF Valuation Dashboard | '
                      f'IV: Rs.{self.per_share_value:,.0f} | '
                      f'Upside: {upside:+.1f}% | {rec}',
                      transform=ax_title.transAxes, ha='center', va='center',
                      fontsize=16, fontweight='bold',
                      bbox=dict(boxstyle='round', facecolor='#1a1a3e', edgecolor='#6c8cff'))

        # --- 2. KPI Cards ---
        kpis = [
            ('Enterprise Value', f'Rs.{self.enterprise_value:,.0f}Cr', '#6c8cff'),
            ('Equity Value', f'Rs.{self.equity_value:,.0f}Cr', '#00c9a7'),
            ('IV / Share', f'Rs.{self.per_share_value:,.0f}', '#f0a040'),
            ('WACC', f'{self.wacc*100:.2f}%', '#e0556a'),
            ('TV % of EV', f'{self.pv_terminal/self.enterprise_value*100:.0f}%', '#6c8cff'),
            ('Upside', f'{upside:+.1f}%', '#00c9a7' if upside > 0 else '#e0556a'),
        ]
        for i, (label, value, color) in enumerate(kpis):
            ax_kpi = fig.add_axes([0.03 + i * 0.155, 0.82, 0.14, 0.09])
            ax_kpi.axis('off')
            ax_kpi.text(0.5, 0.55, value, transform=ax_kpi.transAxes,
                       ha='center', va='center', fontsize=16, fontweight='bold', color=color)
            ax_kpi.text(0.5, 0.15, label, transform=ax_kpi.transAxes,
                       ha='center', va='center', fontsize=9, color='gray')
            ax_kpi.add_patch(plt.Rectangle((0, 0), 1, 1, fill=False,
                            edgecolor=color, linewidth=1.5,
                            transform=ax_kpi.transAxes))

        # --- 3. FCFF Forecast Chart ---
        ax_fcff = fig.add_axes([0.03, 0.55, 0.45, 0.24])
        years = np.arange(1, self.forecast_years + 1)
        ax_fcff.bar(years, self.fcff_forecast, color='#6c8cff', edgecolor='white')
        ax_fcff.plot(years, self.fcff_forecast, 'o-', color='#00c9a7', linewidth=2, markersize=8)
        for y, f in zip(years, self.fcff_forecast):
            ax_fcff.text(y, f + max(self.fcff_forecast) * 0.02, f'{f:,.0f}',
                        ha='center', fontsize=9, fontweight='bold')
        ax_fcff.set_title('FCFF Forecast (Rs. Cr)', fontweight='bold', fontsize=12)
        ax_fcff.set_ylabel('Rs. Crore')
        ax_fcff.grid(True, alpha=0.3, axis='y')

        # --- 4. Value Bridge ---
        ax_bridge = fig.add_axes([0.53, 0.55, 0.45, 0.24])
        bridge_items = ['PV\nExplicit', 'PV\nTerminal', 'Enterprise\nValue', '(−)\nNet Debt', 'Equity\nValue']
        bridge_vals = [self.pv_explicit, self.pv_terminal, self.enterprise_value,
                       -self.net_debt, self.equity_value]
        colors_bridge = ['#6c8cff', '#00c9a7', '#6c8cff', '#e0556a', '#00c9a7']
        bottoms_bridge = [0, 0, 0, 0, 0]
        # Compute waterfall bottoms
        running = 0
        for i, v in enumerate(bridge_vals):
            if v >= 0:
                bottoms_bridge[i] = running
            else:
                bottoms_bridge[i] = running + v
            running += v
        ax_bridge.bar(bridge_items, bridge_vals, bottom=bottoms_bridge,
                      color=colors_bridge, edgecolor='white', linewidth=1.5)
        for i, v in enumerate(bridge_vals):
            y_pos = bottoms_bridge[i] + v / 2
            ax_bridge.text(i, y_pos, f'Rs.{v:,.0f}Cr', ha='center',
                          fontsize=9, fontweight='bold',
                          color='white' if abs(v) > max(bridge_vals) * 0.03 else 'black')
        ax_bridge.set_title('Enterprise Value → Equity Value Bridge', fontweight='bold', fontsize=12)
        ax_bridge.grid(True, alpha=0.3, axis='y')

        # --- 5. WACC Decomposition ---
        ax_wacc = fig.add_axes([0.03, 0.27, 0.45, 0.24])
        wacc_parts = ['Risk-Free\nRate', 'ERP\n(β×ERP)', 'After-Tax\nCost of Debt']
        wacc_contribs = [self.rf * self.equity_weight,
                         self.adj_beta * self.india_erp * self.equity_weight,
                         self.kd_post * self.debt_weight]
        ax_wacc.barh(wacc_parts, [c * 100 for c in wacc_contribs],
                     color=['#6c8cff', '#00c9a7', '#f0a040'], edgecolor='white')
        ax_wacc.axvline(x=self.wacc * 100, color='#e0556a', linestyle='--', linewidth=2,
                        label=f'WACC = {self.wacc*100:.2f}%')
        ax_wacc.set_xlabel('Contribution to WACC (%)')
        ax_wacc.set_title('WACC Decomposition', fontweight='bold', fontsize=12)
        ax_wacc.legend(fontsize=9)
        ax_wacc.grid(True, alpha=0.3, axis='x')

        # --- 6. Key Assumptions Table ---
        ax_table = fig.add_axes([0.53, 0.27, 0.45, 0.24])
        ax_table.axis('off')
        table_data = [
            ['Risk-Free Rate', f'{self.rf*100:.2f}%', '10Y G-Sec'],
            ['Adjusted Beta', f'{self.adj_beta:.2f}', '5Y Monthly vs Nifty'],
            ['India ERP', f'{self.india_erp*100:.2f}%', 'US 5.0% + CRP 2.25%'],
            ['Cost of Equity', f'{self.ke*100:.2f}%', 'CAPM'],
            ['Pre-Tax Kd', f'{self.kd_pre*100:.2f}%', 'Rf + Credit Spread'],
            ['WACC', f'{self.wacc*100:.2f}%', 'Market-Value Weights'],
            ['Terminal Growth', f'{self.terminal_g*100:.1f}%', 'Long-Run Nominal GDP'],
            ['Tax Rate', f'{self.tax_rate*100:.0f}%', 'New Regime'],
        ]
        table = ax_table.table(cellText=table_data,
                               colLabels=['Parameter', 'Value', 'Source'],
                               cellLoc='left', loc='center',
                               colWidths=[0.35, 0.25, 0.40])
        table.auto_set_font_size(False)
        table.set_fontsize(9)
        for key, cell in table.get_celld().items():
            cell.set_edgecolor('#2a2a45')
            if key[0] == 0:
                cell.set_text_props(weight='bold', color='#6c8cff')
                cell.set_facecolor('#1c1c2e')
            else:
                cell.set_facecolor('#161625')
                cell.set_text_props(color='#e4e4ed')
        ax_table.set_title('Key Assumptions', fontweight='bold', fontsize=12, y=1.02)

        # --- 7. Sensitivity Mini-Table ---
        ax_sens = fig.add_axes([0.03, 0.05, 0.45, 0.18])
        ax_sens.axis('off')
        sens_data = [
            ['WACC −1%', f'Rs. {self._quick_iv(wacc=self.wacc-0.01):,.0f}'],
            ['WACC +1%', f'Rs. {self._quick_iv(wacc=self.wacc+0.01):,.0f}'],
            ['g −0.5%', f'Rs. {self._quick_iv(terminal_g=self.terminal_g-0.005):,.0f}'],
            ['g +0.5%', f'Rs. {self._quick_iv(terminal_g=self.terminal_g+0.005):,.0f}'],
            ['Growth −2%', f'Rs. {self._quick_iv(growth=self.forecast["Growth"].iloc[0]-0.02):,.0f}'],
            ['Growth +2%', f'Rs. {self._quick_iv(growth=self.forecast["Growth"].iloc[0]+0.02):,.0f}'],
        ]
        sens_table = ax_sens.table(cellText=sens_data, colLabels=['Sensitivity', 'IV/Share'],
                                   cellLoc='left', loc='center',
                                   colWidths=[0.45, 0.55])
        sens_table.auto_set_font_size(False)
        sens_table.set_fontsize(9)
        for key, cell in sens_table.get_celld().items():
            cell.set_edgecolor('#2a2a45')
            if key[0] == 0:
                cell.set_text_props(weight='bold', color='#6c8cff')
                cell.set_facecolor('#1c1c2e')
            else:
                cell.set_facecolor('#161625')
                cell.set_text_props(color='#e4e4ed')
        ax_sens.set_title('Quick Sensitivity', fontweight='bold', fontsize=12, y=1.05)

        # --- 8. Recommendation Box ---
        ax_rec = fig.add_axes([0.53, 0.05, 0.45, 0.18])
        ax_rec.axis('off')
        rec_color = '#00c9a7' if upside > 0 else '#e0556a'
        ax_rec.add_patch(plt.Rectangle((0, 0), 1, 1, fill=True,
                         facecolor='#161625', edgecolor=rec_color, linewidth=2))
        ax_rec.text(0.5, 0.7, rec, transform=ax_rec.transAxes, ha='center', va='center',
                   fontsize=32, fontweight='bold', color=rec_color)
        ax_rec.text(0.5, 0.35, f'Intrinsic Value: Rs.{self.per_share_value:,.0f} | '
                   f'Market: Rs.{self.market_price:,.0f} | Upside: {upside:+.1f}%',
                   transform=ax_rec.transAxes, ha='center', va='center',
                   fontsize=10, color='gray')
        ax_rec.text(0.5, 0.12, 'Based on DCF analysis. This is not investment advice. '
                   'See full report for assumptions and risks.',
                   transform=ax_rec.transAxes, ha='center', va='center',
                   fontsize=7, color='#6b6b80')

        plt.suptitle('')
        plt.show()

    def _quick_iv(self, **overrides):
        """Fast IV recompute for sensitivity display."""
        g_list = [overrides.get('growth', self.forecast['Growth'].iloc[0]) * (0.95**i)
                  for i in range(self.forecast_years)]
        op_m = self.forecast['EBIT'].iloc[0] / self.forecast['Revenue'].iloc[0]
        w = overrides.get('wacc', self.wacc)
        tg = overrides.get('terminal_g', self.terminal_g)
        prev_r = self.historical['Revenue']
        prev_w = prev_r * 0.12
        ffs = []
        for gv in g_list:
            rev = prev_r * (1 + gv)
            nopat = rev * op_m * (1 - self.tax_rate)
            de = rev * 0.03
            cx = rev * 0.06
            dwc = rev * 0.15 - prev_w
            ffs.append(nopat + de - cx - dwc)
            prev_r = rev
            prev_w = rev * 0.15
        pv_e = sum(f / (1 + w) ** (t + 0.5) for t, f in enumerate(ffs, 1))
        tv = ffs[-1] * (1 + tg) / (w - tg)
        pv_t = tv / (1 + w) ** self.forecast_years
        return (pv_e + pv_t - self.net_debt) / self.shares_outstanding


# ================================================================
# USAGE
# ================================================================
# dcf = DCFValuation('ASIANPAINT.NS', terminal_g=0.035)
# dcf.run()
# dcf.summary()
# tornado = dcf.tornado()
# dcf.waterfall()
# dcf.sensitivity_heatmaps()
# dcf.dashboard()

14.2 The Tornado Chart: Identifying Value Drivers

The tornado chart from DCFValuation.tornado() ranks every key assumption by its impact on per-share intrinsic value. It answers: where should I focus my analytical effort?

The chart typically reveals a clear hierarchy:

RankParameterTypical Impact (±20% change)Action
1WACC±15–25% on IVTriangulate with multiple beta/ERP estimates. Defend your WACC with peer benchmarking.
2Operating Margin±12–20% on IVAnalyze historical margin stability. Is the company a price-taker or price-setter?
3Revenue Growth±10–18% on IVValidate against industry growth, market share trends, and addressable market size.
4Terminal Growth±8–15% on IVCheck implied exit multiple. Ensure g < long-run GDP growth.
5Capex / Revenue±5–10% on IVCompare to historical reinvestment needs and industry norms.
6Tax Rate±3–6% on IVVerify the company's tax regime choice. MAT credits?
💡
Pro Tip: The top 2–3 bars on the tornado chart are the assumptions you will be grilled on in an investment committee. Prepare detailed evidence for each. For WACC, have your beta regression statistics, peer comparison, and ERP source ready. For margins, have the 10-year historical margin chart. The bars at the bottom of the tornado — even though they look small — can still matter. A 3% impact on a Rs. 50,000 Cr valuation is Rs. 1,500 Cr of shareholder value.

14.3 The Valuation Waterfall: Telling the Value Creation Story

While the tornado chart answers "what drives value?", the valuation waterfall answers "where does value come from?" It decomposes the per-share intrinsic value into its components — each year's discounted FCFF, the terminal value, and the net debt adjustment — showing how the value builds from individual cash flows to the final IV.

The waterfall makes one fact immediately visible: the terminal value is typically the largest single bar. When presenting to stakeholders, this visual is far more effective than a table of numbers. It tells the story: "The company generates Rs. X of value from the next 5 years of cash flows, Rs. Y from its long-term franchise value, and after subtracting net debt, the equity is worth Rs. Z per share."

The DCFValuation.waterfall() method generates this chart with automatic annotations and connector lines.

Session 15 — Sensitivity Analysis & Valuation Waterfall

14.4 Two-Way Sensitivity Heatmaps

The heatmaps from DCFValuation.sensitivity_heatmaps() are the most information-dense exhibits in a DCF presentation. Each cell in the grid shows the per-share intrinsic value for a specific combination of two assumptions. The color gradient instantly reveals where value lives and dies.

Reading the heatmaps:

📝
Presentation Tip: In an investment committee meeting, put the WACC vs Terminal Growth heatmap on the screen and ask: "At what WACC and terminal growth combination does this stock become fairly valued at the current market price?" Trace the market price isoquant across the heatmap. This transforms the discussion from "is the valuation right?" to "do you believe the assumptions that justify the current price?"

14.5 The One-Page DCF Dashboard

The DCFValuation.dashboard() method assembles all key exhibits into a single, presentation-ready page. This is the output you would include in an investment committee memo, a pitchbook, or a capstone project submission. The dashboard contains:

PanelPositionContent
Title BarTopCompany name, IV/share, upside %, recommendation
KPI Cards (6)Upper rowEV, Equity Value, IV/Share, WACC, TV%, Upside
FCFF ForecastMiddle-leftBar chart of 5-year FCFF with values
Value BridgeMiddle-rightWaterfall: PV Explicit → PV Terminal → EV → Equity Value
WACC DecompositionLower-leftHorizontal bar: Rf contribution, ERP contribution, Kd contribution
Key AssumptionsLower-rightTable of 8 parameters with values and sources
Quick SensitivityBottom-leftIV/share at ±1% WACC, ±0.5% g, ±2% growth
Recommendation BoxBottom-rightBUY/HOLD/SELL with IV, market price, and disclaimer

Hands-On Project: Complete DCF Valuation for Your Capstone Company

Run the complete DCF valuation using the DCFValuation class for your capstone company. Generate all exhibits — summary, tornado, waterfall, heatmaps, and dashboard. This is the final output of Part V and the centerpiece of your capstone project.

Steps

  1. Instantiate and run: dcf = DCFValuation('YOUR_TICKER.NS', terminal_g=0.035); dcf.run()
  2. Review the summary output. Check: Does the FCFF trajectory make sense? Is TV% of EV between 55–80%? Is the upside/downside plausible?
  3. Generate the tornado chart. Identify your top 3 value drivers. For each, write one sentence explaining why it ranks where it does for your company.
  4. Generate the waterfall chart. Note which year's FCFF contributes most to present value. Is Year 5 FCFF larger than Year 1? Why?
  5. Generate sensitivity heatmaps. Find the cell closest to the current market price. What WACC and terminal growth does that cell imply? Are those assumptions reasonable?
  6. Generate the dashboard. This is your capstone summary exhibit.
  7. Write a 300-word investment conclusion: What is your recommended action (Buy/Hold/Sell)? What are the 2–3 key assumptions that must hold true for your recommendation to be correct? What is the biggest risk?

Key Takeaways

1

The DCFValuation class is your capstone engine. It integrates every concept from Chapters 1–13 into a single, reusable Python class that produces a complete DCF from ticker to per-share value.

2

The tornado chart tells you where to focus. WACC, operating margin, and revenue growth typically dominate. Spend your analytical time on the top 3 bars — they drive 70%+ of the valuation uncertainty.

3

The waterfall tells the value creation story. It shows exactly how much value comes from each year's cash flows and the terminal franchise — far more powerful than a table of numbers.

4

Heatmaps reveal assumption interactions. The WACC × Terminal Growth and Growth × Margin grids show where value lives and dies — essential for understanding model behavior.

5

The one-page dashboard is your deliverable. An investment committee will not read your 50-page model. They will look at one page. Make it count — KPI cards, bridge, sensitivity, and a clear recommendation.

Test Your Understanding

1. In a DCF tornado chart, what does the length of each bar represent?

2. What does the valuation waterfall chart primarily communicate?

3. In the WACC vs Terminal Growth sensitivity heatmap, the cell showing the highest IV/share is always in which corner?

4. The DCFValuation class's dashboard includes 6 KPI cards. Which of the following is NOT typically one of them?

5. Your tornado chart shows WACC as the #1 driver, with a ±22% impact on IV. Operating Margin is #2 at ±16%. An investment committee member asks: "So your valuation is mostly a bet on interest rates?" How do you respond?