Build campaign-analytics, financial-analyst, customer-success-manager, sales-engineer, and revenue-operations skills using the Claude Skills Factory workflow. Each skill includes SKILL.md, Python CLI tools, reference guides, and asset templates. All 16 Python scripts use standard library only with --format json/text support. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
450 lines
16 KiB
Python
450 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
DCF Valuation Model
|
|
|
|
Discounted Cash Flow enterprise and equity valuation with WACC calculation,
|
|
terminal value estimation, and two-way sensitivity analysis.
|
|
|
|
Uses standard library only (math, statistics) - NO numpy/pandas/scipy.
|
|
|
|
Usage:
|
|
python dcf_valuation.py valuation_data.json
|
|
python dcf_valuation.py valuation_data.json --format json
|
|
python dcf_valuation.py valuation_data.json --projection-years 7
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import sys
|
|
from statistics import mean
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
|
|
def safe_divide(numerator: float, denominator: float, default: float = 0.0) -> float:
|
|
"""Safely divide two numbers, returning default if denominator is zero."""
|
|
if denominator == 0 or denominator is None:
|
|
return default
|
|
return numerator / denominator
|
|
|
|
|
|
class DCFModel:
|
|
"""Discounted Cash Flow valuation model."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize the DCF model."""
|
|
self.historical: Dict[str, Any] = {}
|
|
self.assumptions: Dict[str, Any] = {}
|
|
self.wacc: float = 0.0
|
|
self.projected_revenue: List[float] = []
|
|
self.projected_fcf: List[float] = []
|
|
self.projection_years: int = 5
|
|
self.terminal_value_perpetuity: float = 0.0
|
|
self.terminal_value_exit_multiple: float = 0.0
|
|
self.enterprise_value_perpetuity: float = 0.0
|
|
self.enterprise_value_exit_multiple: float = 0.0
|
|
self.equity_value_perpetuity: float = 0.0
|
|
self.equity_value_exit_multiple: float = 0.0
|
|
self.value_per_share_perpetuity: float = 0.0
|
|
self.value_per_share_exit_multiple: float = 0.0
|
|
|
|
def set_historical_financials(self, historical: Dict[str, Any]) -> None:
|
|
"""Set historical financial data."""
|
|
self.historical = historical
|
|
|
|
def set_assumptions(self, assumptions: Dict[str, Any]) -> None:
|
|
"""Set projection assumptions."""
|
|
self.assumptions = assumptions
|
|
self.projection_years = assumptions.get("projection_years", 5)
|
|
|
|
def calculate_wacc(self) -> float:
|
|
"""Calculate Weighted Average Cost of Capital via CAPM."""
|
|
wacc_inputs = self.assumptions.get("wacc_inputs", {})
|
|
|
|
risk_free_rate = wacc_inputs.get("risk_free_rate", 0.04)
|
|
equity_risk_premium = wacc_inputs.get("equity_risk_premium", 0.06)
|
|
beta = wacc_inputs.get("beta", 1.0)
|
|
cost_of_debt = wacc_inputs.get("cost_of_debt", 0.05)
|
|
tax_rate = wacc_inputs.get("tax_rate", 0.25)
|
|
debt_weight = wacc_inputs.get("debt_weight", 0.30)
|
|
equity_weight = wacc_inputs.get("equity_weight", 0.70)
|
|
|
|
# CAPM: Cost of Equity = Risk-Free Rate + Beta * Equity Risk Premium
|
|
cost_of_equity = risk_free_rate + beta * equity_risk_premium
|
|
|
|
# WACC = (E/V * Re) + (D/V * Rd * (1 - T))
|
|
after_tax_cost_of_debt = cost_of_debt * (1 - tax_rate)
|
|
self.wacc = (equity_weight * cost_of_equity) + (
|
|
debt_weight * after_tax_cost_of_debt
|
|
)
|
|
|
|
return self.wacc
|
|
|
|
def project_cash_flows(self) -> Tuple[List[float], List[float]]:
|
|
"""Project revenue and free cash flow over the projection period."""
|
|
base_revenue = self.historical.get("revenue", [])
|
|
if not base_revenue:
|
|
raise ValueError("Historical revenue data is required")
|
|
|
|
last_revenue = base_revenue[-1]
|
|
|
|
revenue_growth_rates = self.assumptions.get("revenue_growth_rates", [])
|
|
fcf_margins = self.assumptions.get("fcf_margins", [])
|
|
|
|
# If growth rates not provided for all years, use average or default
|
|
default_growth = self.assumptions.get("default_revenue_growth", 0.05)
|
|
default_fcf_margin = self.assumptions.get("default_fcf_margin", 0.10)
|
|
|
|
self.projected_revenue = []
|
|
self.projected_fcf = []
|
|
current_revenue = last_revenue
|
|
|
|
for year in range(self.projection_years):
|
|
growth = (
|
|
revenue_growth_rates[year]
|
|
if year < len(revenue_growth_rates)
|
|
else default_growth
|
|
)
|
|
fcf_margin = (
|
|
fcf_margins[year]
|
|
if year < len(fcf_margins)
|
|
else default_fcf_margin
|
|
)
|
|
|
|
current_revenue = current_revenue * (1 + growth)
|
|
fcf = current_revenue * fcf_margin
|
|
|
|
self.projected_revenue.append(current_revenue)
|
|
self.projected_fcf.append(fcf)
|
|
|
|
return self.projected_revenue, self.projected_fcf
|
|
|
|
def calculate_terminal_value(self) -> Tuple[float, float]:
|
|
"""Calculate terminal value using both perpetuity growth and exit multiple."""
|
|
if not self.projected_fcf:
|
|
raise ValueError("Must project cash flows before terminal value")
|
|
|
|
terminal_fcf = self.projected_fcf[-1]
|
|
terminal_growth = self.assumptions.get("terminal_growth_rate", 0.025)
|
|
exit_multiple = self.assumptions.get("exit_ev_ebitda_multiple", 12.0)
|
|
|
|
# Perpetuity growth method: TV = FCF * (1+g) / (WACC - g)
|
|
if self.wacc > terminal_growth:
|
|
self.terminal_value_perpetuity = (
|
|
terminal_fcf * (1 + terminal_growth)
|
|
) / (self.wacc - terminal_growth)
|
|
else:
|
|
self.terminal_value_perpetuity = 0.0
|
|
|
|
# Exit multiple method: TV = Terminal EBITDA * Exit Multiple
|
|
terminal_revenue = self.projected_revenue[-1]
|
|
ebitda_margin = self.assumptions.get("terminal_ebitda_margin", 0.20)
|
|
terminal_ebitda = terminal_revenue * ebitda_margin
|
|
self.terminal_value_exit_multiple = terminal_ebitda * exit_multiple
|
|
|
|
return self.terminal_value_perpetuity, self.terminal_value_exit_multiple
|
|
|
|
def calculate_enterprise_value(self) -> Tuple[float, float]:
|
|
"""Calculate enterprise value by discounting projected FCFs and terminal value."""
|
|
if not self.projected_fcf:
|
|
raise ValueError("Must project cash flows first")
|
|
|
|
# Discount projected FCFs
|
|
pv_fcf = 0.0
|
|
for i, fcf in enumerate(self.projected_fcf):
|
|
discount_factor = (1 + self.wacc) ** (i + 1)
|
|
pv_fcf += fcf / discount_factor
|
|
|
|
# Discount terminal values
|
|
terminal_discount = (1 + self.wacc) ** self.projection_years
|
|
|
|
pv_tv_perpetuity = self.terminal_value_perpetuity / terminal_discount
|
|
pv_tv_exit = self.terminal_value_exit_multiple / terminal_discount
|
|
|
|
self.enterprise_value_perpetuity = pv_fcf + pv_tv_perpetuity
|
|
self.enterprise_value_exit_multiple = pv_fcf + pv_tv_exit
|
|
|
|
return self.enterprise_value_perpetuity, self.enterprise_value_exit_multiple
|
|
|
|
def calculate_equity_value(self) -> Tuple[float, float]:
|
|
"""Calculate equity value from enterprise value."""
|
|
net_debt = self.historical.get("net_debt", 0)
|
|
shares_outstanding = self.historical.get("shares_outstanding", 1)
|
|
|
|
self.equity_value_perpetuity = (
|
|
self.enterprise_value_perpetuity - net_debt
|
|
)
|
|
self.equity_value_exit_multiple = (
|
|
self.enterprise_value_exit_multiple - net_debt
|
|
)
|
|
|
|
self.value_per_share_perpetuity = safe_divide(
|
|
self.equity_value_perpetuity, shares_outstanding
|
|
)
|
|
self.value_per_share_exit_multiple = safe_divide(
|
|
self.equity_value_exit_multiple, shares_outstanding
|
|
)
|
|
|
|
return self.equity_value_perpetuity, self.equity_value_exit_multiple
|
|
|
|
def sensitivity_analysis(
|
|
self,
|
|
wacc_range: Optional[List[float]] = None,
|
|
growth_range: Optional[List[float]] = None,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Two-way sensitivity analysis: WACC vs terminal growth rate.
|
|
|
|
Returns a table of enterprise values using nested lists (no numpy).
|
|
"""
|
|
if wacc_range is None:
|
|
base_wacc = self.wacc
|
|
wacc_range = [
|
|
round(base_wacc - 0.02, 4),
|
|
round(base_wacc - 0.01, 4),
|
|
round(base_wacc, 4),
|
|
round(base_wacc + 0.01, 4),
|
|
round(base_wacc + 0.02, 4),
|
|
]
|
|
|
|
if growth_range is None:
|
|
base_growth = self.assumptions.get("terminal_growth_rate", 0.025)
|
|
growth_range = [
|
|
round(base_growth - 0.01, 4),
|
|
round(base_growth - 0.005, 4),
|
|
round(base_growth, 4),
|
|
round(base_growth + 0.005, 4),
|
|
round(base_growth + 0.01, 4),
|
|
]
|
|
|
|
rows = len(wacc_range)
|
|
cols = len(growth_range)
|
|
|
|
# Initialize sensitivity table as nested lists
|
|
ev_table = [[0.0] * cols for _ in range(rows)]
|
|
share_price_table = [[0.0] * cols for _ in range(rows)]
|
|
|
|
terminal_fcf = self.projected_fcf[-1] if self.projected_fcf else 0
|
|
|
|
for i, wacc_val in enumerate(wacc_range):
|
|
for j, growth_val in enumerate(growth_range):
|
|
if wacc_val <= growth_val:
|
|
ev_table[i][j] = float("inf")
|
|
share_price_table[i][j] = float("inf")
|
|
continue
|
|
|
|
# Recalculate PV of projected FCFs with this WACC
|
|
pv_fcf = 0.0
|
|
for k, fcf in enumerate(self.projected_fcf):
|
|
pv_fcf += fcf / ((1 + wacc_val) ** (k + 1))
|
|
|
|
# Terminal value with this growth rate
|
|
tv = (terminal_fcf * (1 + growth_val)) / (wacc_val - growth_val)
|
|
pv_tv = tv / ((1 + wacc_val) ** self.projection_years)
|
|
|
|
ev = pv_fcf + pv_tv
|
|
ev_table[i][j] = round(ev, 2)
|
|
|
|
net_debt = self.historical.get("net_debt", 0)
|
|
shares = self.historical.get("shares_outstanding", 1)
|
|
equity = ev - net_debt
|
|
share_price_table[i][j] = round(
|
|
safe_divide(equity, shares), 2
|
|
)
|
|
|
|
return {
|
|
"wacc_values": wacc_range,
|
|
"growth_values": growth_range,
|
|
"enterprise_value_table": ev_table,
|
|
"share_price_table": share_price_table,
|
|
}
|
|
|
|
def run_full_valuation(self) -> Dict[str, Any]:
|
|
"""Run the complete DCF valuation."""
|
|
self.calculate_wacc()
|
|
self.project_cash_flows()
|
|
self.calculate_terminal_value()
|
|
self.calculate_enterprise_value()
|
|
self.calculate_equity_value()
|
|
sensitivity = self.sensitivity_analysis()
|
|
|
|
return {
|
|
"wacc": self.wacc,
|
|
"projected_revenue": self.projected_revenue,
|
|
"projected_fcf": self.projected_fcf,
|
|
"terminal_value": {
|
|
"perpetuity_growth": self.terminal_value_perpetuity,
|
|
"exit_multiple": self.terminal_value_exit_multiple,
|
|
},
|
|
"enterprise_value": {
|
|
"perpetuity_growth": self.enterprise_value_perpetuity,
|
|
"exit_multiple": self.enterprise_value_exit_multiple,
|
|
},
|
|
"equity_value": {
|
|
"perpetuity_growth": self.equity_value_perpetuity,
|
|
"exit_multiple": self.equity_value_exit_multiple,
|
|
},
|
|
"value_per_share": {
|
|
"perpetuity_growth": self.value_per_share_perpetuity,
|
|
"exit_multiple": self.value_per_share_exit_multiple,
|
|
},
|
|
"sensitivity_analysis": sensitivity,
|
|
}
|
|
|
|
def format_text(self, results: Dict[str, Any]) -> str:
|
|
"""Format valuation results as human-readable text."""
|
|
lines: List[str] = []
|
|
lines.append("=" * 70)
|
|
lines.append("DCF VALUATION ANALYSIS")
|
|
lines.append("=" * 70)
|
|
|
|
def fmt_money(val: float) -> str:
|
|
if val == float("inf"):
|
|
return "N/A (WACC <= growth)"
|
|
if abs(val) >= 1e9:
|
|
return f"${val / 1e9:,.2f}B"
|
|
if abs(val) >= 1e6:
|
|
return f"${val / 1e6:,.2f}M"
|
|
if abs(val) >= 1e3:
|
|
return f"${val / 1e3:,.1f}K"
|
|
return f"${val:,.2f}"
|
|
|
|
lines.append(f"\n--- WACC ---")
|
|
lines.append(f" Weighted Average Cost of Capital: {results['wacc'] * 100:.2f}%")
|
|
|
|
lines.append(f"\n--- REVENUE PROJECTIONS ---")
|
|
for i, rev in enumerate(results["projected_revenue"], 1):
|
|
lines.append(f" Year {i}: {fmt_money(rev)}")
|
|
|
|
lines.append(f"\n--- FREE CASH FLOW PROJECTIONS ---")
|
|
for i, fcf in enumerate(results["projected_fcf"], 1):
|
|
lines.append(f" Year {i}: {fmt_money(fcf)}")
|
|
|
|
lines.append(f"\n--- TERMINAL VALUE ---")
|
|
lines.append(
|
|
f" Perpetuity Growth Method: "
|
|
f"{fmt_money(results['terminal_value']['perpetuity_growth'])}"
|
|
)
|
|
lines.append(
|
|
f" Exit Multiple Method: "
|
|
f"{fmt_money(results['terminal_value']['exit_multiple'])}"
|
|
)
|
|
|
|
lines.append(f"\n--- ENTERPRISE VALUE ---")
|
|
lines.append(
|
|
f" Perpetuity Growth Method: "
|
|
f"{fmt_money(results['enterprise_value']['perpetuity_growth'])}"
|
|
)
|
|
lines.append(
|
|
f" Exit Multiple Method: "
|
|
f"{fmt_money(results['enterprise_value']['exit_multiple'])}"
|
|
)
|
|
|
|
lines.append(f"\n--- EQUITY VALUE ---")
|
|
lines.append(
|
|
f" Perpetuity Growth Method: "
|
|
f"{fmt_money(results['equity_value']['perpetuity_growth'])}"
|
|
)
|
|
lines.append(
|
|
f" Exit Multiple Method: "
|
|
f"{fmt_money(results['equity_value']['exit_multiple'])}"
|
|
)
|
|
|
|
lines.append(f"\n--- VALUE PER SHARE ---")
|
|
vps = results["value_per_share"]
|
|
lines.append(f" Perpetuity Growth Method: ${vps['perpetuity_growth']:,.2f}")
|
|
lines.append(f" Exit Multiple Method: ${vps['exit_multiple']:,.2f}")
|
|
|
|
# Sensitivity table
|
|
sens = results["sensitivity_analysis"]
|
|
lines.append(f"\n--- SENSITIVITY ANALYSIS (Enterprise Value) ---")
|
|
lines.append(f" WACC vs Terminal Growth Rate")
|
|
lines.append("")
|
|
|
|
header = " {:>10s}".format("WACC \\ g")
|
|
for g in sens["growth_values"]:
|
|
header += f" {g * 100:>8.1f}%"
|
|
lines.append(header)
|
|
lines.append(" " + "-" * (10 + 10 * len(sens["growth_values"])))
|
|
|
|
for i, w in enumerate(sens["wacc_values"]):
|
|
row = f" {w * 100:>9.1f}%"
|
|
for j in range(len(sens["growth_values"])):
|
|
val = sens["enterprise_value_table"][i][j]
|
|
if val == float("inf"):
|
|
row += f" {'N/A':>8s}"
|
|
else:
|
|
row += f" {fmt_money(val):>8s}"
|
|
lines.append(row)
|
|
|
|
lines.append("\n" + "=" * 70)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main() -> None:
|
|
"""Main entry point."""
|
|
parser = argparse.ArgumentParser(
|
|
description="DCF Valuation Model - Enterprise and equity valuation"
|
|
)
|
|
parser.add_argument(
|
|
"input_file",
|
|
help="Path to JSON file with valuation data",
|
|
)
|
|
parser.add_argument(
|
|
"--format",
|
|
choices=["text", "json"],
|
|
default="text",
|
|
help="Output format (default: text)",
|
|
)
|
|
parser.add_argument(
|
|
"--projection-years",
|
|
type=int,
|
|
default=None,
|
|
help="Number of projection years (overrides input file)",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
with open(args.input_file, "r") as f:
|
|
data = json.load(f)
|
|
except FileNotFoundError:
|
|
print(f"Error: File '{args.input_file}' not found.", file=sys.stderr)
|
|
sys.exit(1)
|
|
except json.JSONDecodeError as e:
|
|
print(f"Error: Invalid JSON in '{args.input_file}': {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
model = DCFModel()
|
|
model.set_historical_financials(data.get("historical", {}))
|
|
|
|
assumptions = data.get("assumptions", {})
|
|
if args.projection_years is not None:
|
|
assumptions["projection_years"] = args.projection_years
|
|
model.set_assumptions(assumptions)
|
|
|
|
try:
|
|
results = model.run_full_valuation()
|
|
except ValueError as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if args.format == "json":
|
|
# Handle inf values for JSON serialization
|
|
def sanitize(obj: Any) -> Any:
|
|
if isinstance(obj, float) and math.isinf(obj):
|
|
return None
|
|
if isinstance(obj, dict):
|
|
return {k: sanitize(v) for k, v in obj.items()}
|
|
if isinstance(obj, list):
|
|
return [sanitize(v) for v in obj]
|
|
return obj
|
|
|
|
print(json.dumps(sanitize(results), indent=2))
|
|
else:
|
|
print(model.format_text(results))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|