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
- Build a complete, reusable DCF valuation class that integrates forecasting, WACC, FCFF, terminal value, and equity value derivation
- Construct tornado charts that rank every input assumption by its impact on per-share intrinsic value
- Build a valuation waterfall chart showing the contribution of each value driver from revenue to equity value
- Create publication-quality sensitivity heatmaps and scenario summary tables
- Produce a one-page DCF output dashboard suitable for an investment committee presentation
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:
| Rank | Parameter | Typical Impact (±20% change) | Action |
|---|---|---|---|
| 1 | WACC | ±15–25% on IV | Triangulate with multiple beta/ERP estimates. Defend your WACC with peer benchmarking. |
| 2 | Operating Margin | ±12–20% on IV | Analyze historical margin stability. Is the company a price-taker or price-setter? |
| 3 | Revenue Growth | ±10–18% on IV | Validate against industry growth, market share trends, and addressable market size. |
| 4 | Terminal Growth | ±8–15% on IV | Check implied exit multiple. Ensure g < long-run GDP growth. |
| 5 | Capex / Revenue | ±5–10% on IV | Compare to historical reinvestment needs and industry norms. |
| 6 | Tax Rate | ±3–6% on IV | Verify the company's tax regime choice. MAT credits? |
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.
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:
- WACC vs Terminal Growth: The top-right corner (low WACC, high g) shows the highest valuation. The bottom-left (high WACC, low g) shows the lowest. A narrow band of green suggests the valuation is highly sensitive to these parameters.
- Revenue Growth vs Operating Margin: The top-right (high growth, high margin) is the bull case. The bottom-left is the bear case. The diagonal pattern reveals whether growth or margin is the dominant driver.
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:
| Panel | Position | Content |
|---|---|---|
| Title Bar | Top | Company name, IV/share, upside %, recommendation |
| KPI Cards (6) | Upper row | EV, Equity Value, IV/Share, WACC, TV%, Upside |
| FCFF Forecast | Middle-left | Bar chart of 5-year FCFF with values |
| Value Bridge | Middle-right | Waterfall: PV Explicit → PV Terminal → EV → Equity Value |
| WACC Decomposition | Lower-left | Horizontal bar: Rf contribution, ERP contribution, Kd contribution |
| Key Assumptions | Lower-right | Table of 8 parameters with values and sources |
| Quick Sensitivity | Bottom-left | IV/share at ±1% WACC, ±0.5% g, ±2% growth |
| Recommendation Box | Bottom-right | BUY/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
- Instantiate and run:
dcf = DCFValuation('YOUR_TICKER.NS', terminal_g=0.035); dcf.run() - Review the summary output. Check: Does the FCFF trajectory make sense? Is TV% of EV between 55–80%? Is the upside/downside plausible?
- 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.
- Generate the waterfall chart. Note which year's FCFF contributes most to present value. Is Year 5 FCFF larger than Year 1? Why?
- 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?
- Generate the dashboard. This is your capstone summary exhibit.
- 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
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.
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.
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.
Heatmaps reveal assumption interactions. The WACC × Terminal Growth and Growth × Margin grids show where value lives and dies — essential for understanding model behavior.
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?