From 1b219bef237232617000f80093976c5fb3f49ffa Mon Sep 17 00:00:00 2001 From: Contributor Date: Wed, 8 Jul 2026 16:59:21 -0500 Subject: [PATCH] Add dividend discount model (DDM) skill to financial-analysis vertical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an income-approach valuation skill — the DDM complement to the existing dcf-model and comps-analysis skills. Values equity directly as the present value of expected dividends discounted at the cost of equity (not WACC), with single-/two-/three-stage (H-model) options, an ROE-driven build for financials, a bundled openpyxl validator, deep methodology reference, and a troubleshooting guide. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../.claude-plugin/plugin.json | 2 +- .../skills/ddm-model/SKILL.md | 171 ++++++++++ .../skills/ddm-model/TROUBLESHOOTING.md | 36 +++ .../ddm-model/references/methodology.md | 111 +++++++ .../skills/ddm-model/requirements.txt | 4 + .../skills/ddm-model/scripts/validate_ddm.py | 291 ++++++++++++++++++ 6 files changed, 614 insertions(+), 1 deletion(-) create mode 100644 plugins/vertical-plugins/financial-analysis/skills/ddm-model/SKILL.md create mode 100644 plugins/vertical-plugins/financial-analysis/skills/ddm-model/TROUBLESHOOTING.md create mode 100644 plugins/vertical-plugins/financial-analysis/skills/ddm-model/references/methodology.md create mode 100644 plugins/vertical-plugins/financial-analysis/skills/ddm-model/requirements.txt create mode 100644 plugins/vertical-plugins/financial-analysis/skills/ddm-model/scripts/validate_ddm.py diff --git a/plugins/vertical-plugins/financial-analysis/.claude-plugin/plugin.json b/plugins/vertical-plugins/financial-analysis/.claude-plugin/plugin.json index 8bb83a79a..2e83c09b6 100644 --- a/plugins/vertical-plugins/financial-analysis/.claude-plugin/plugin.json +++ b/plugins/vertical-plugins/financial-analysis/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "financial-analysis", - "version": "0.1.1", + "version": "0.1.2", "description": "Core financial modeling and analysis tools: DCF, comps, LBO, 3-statement models, competitive analysis, and deck QC", "author": { "name": "Anthropic FSI" diff --git a/plugins/vertical-plugins/financial-analysis/skills/ddm-model/SKILL.md b/plugins/vertical-plugins/financial-analysis/skills/ddm-model/SKILL.md new file mode 100644 index 000000000..7a7299dc7 --- /dev/null +++ b/plugins/vertical-plugins/financial-analysis/skills/ddm-model/SKILL.md @@ -0,0 +1,171 @@ +--- +name: ddm-model +description: | + Build institutional-grade Dividend Discount Model (DDM) valuations — the income-approach complement to the dcf-model and comps-analysis skills. Projects a company's expected dividend stream, discounts it at the cost of equity (CAPM), and outputs a professional Excel model with single-stage (Gordon), two-stage, and multi-stage (H-model) options, sensitivity analysis, and an intrinsic equity value per share. Use when valuing dividend-paying equities — banks, insurers, REITs, utilities, and mature high-payout companies — where free-cash-flow DCF is unreliable or dividends are the cleanest measure of cash returned to shareholders. + + **Perfect for:** + - Valuing banks, insurers, and other financials where unlevered free cash flow is ill-defined + - Mature, stable dividend payers (utilities, consumer staples, telecoms) + - REITs and income vehicles valued on their distributions + - An income-based cross-check on a DCF or trading-comps valuation + - Backing out the dividend growth the market is pricing into a stock (implied-growth check) + + **Not ideal for:** + - Non-dividend-paying or early-stage growth companies (use dcf-model / comps-analysis) + - Companies whose payout is erratic or funded by debt rather than earnings + - Situations that need enterprise value (DDM yields equity value per share directly) + - Making an investment recommendation — this produces an intrinsic value per share, staged for human review +--- + +# Dividend Discount Model (DDM) Builder + +## Overview + +This skill builds institutional-quality DDM valuations for dividend-paying equities. The DDM values equity **directly** as the present value of expected future dividends, discounted at the **cost of equity** — there is no WACC and no enterprise-value-to-equity bridge. Each analysis produces a detailed Excel model (a `DDM` sheet plus a `Sensitivity` sheet) and an intrinsic value per share. + +The deep methodology — single-, two-, and three-stage math, the H-model, the ROE-driven build for financials, and how to reconcile DDM with DCF and comps — lives in [references/methodology.md](references/methodology.md). **Read it before building a multi-stage or a bank/insurer model.** + +## ⚠️ CRITICAL: Data Source Priority (READ FIRST) + +**ALWAYS follow this data source hierarchy for financial and market inputs (dividend history, payout, EPS, book value, beta, risk-free rate):** + +1. **FIRST: Check for MCP data sources** — if S&P Kensho, FactSet, Daloopa, Morningstar, or LSEG MCP servers are available, use them exclusively. +2. **DO NOT use web search** if the above MCP data sources are available. +3. **ONLY if MCPs are unavailable:** use Bloomberg Terminal, SEC EDGAR filings, or other institutional sources. +4. **NEVER use web search as a primary data source** — it lacks the accuracy, audit trails, and point-in-time reliability required for institutional-grade analysis. + +**Why this matters:** a valuation is only as defensible as its inputs. Every hardcoded figure in the model must carry a source and an as-of date so a reviewer can reproduce it. + +## When to Use This Skill (and how it differs from dcf-model) + +- **DDM discounts dividends at the cost of equity and yields equity value per share directly** — no WACC, no unlevered free cash flow, no net-debt bridge. +- Prefer DDM over `dcf-model` when free cash flow is unreliable or undefined (**banks, insurers**), or when dividends are the primary form of return (**utilities, REITs, mature payers**). +- **It complements the other valuation skills.** Run it alongside `dcf-model` and `comps-analysis` and reconcile the outputs into a valuation range (see [references/methodology.md](references/methodology.md) → *Reconciliation*). +- The discount rate is the **cost of equity** from CAPM (`rₑ = r_f + β · ERP`) — a DDM never uses WACC, so there is no debt cost and no market-value weighting. The `dcf-model` skill derives the same CAPM cost of equity in its WACC step; reuse that build for the cost-of-equity portion only. + +## Tools + +- Default to using the data provided by the user and the MCP servers available for sourcing dividends, payout history, EPS, book value, beta, and yields. + +## Critical Constraints — Read These First + +These apply throughout. Review before starting. + +**Environment: Office JS vs Python/openpyxl:** +- **Inside Excel (Office Add-in / Office JS):** write formulas via `range.formulas = [[...]]`; do NOT use Python/openpyxl. Excel recalculates natively. +- **Generating a standalone .xlsx (no live Excel):** use Python/openpyxl, then run `recalc.py` (from the `xlsx-author` skill) before delivery. +- All principles below (formula strings, cell comments, section checkpoints, sensitivity loops) apply identically in both environments. + +**Discount at the COST OF EQUITY, not WACC (the #1 DDM error):** +- Dividends are cash flows to equity holders, so they are discounted at the cost of equity `rₑ = r_f + β · ERP`. Using WACC understates the discount rate and overstates value. This is the single most common DDM mistake — do not make it. + +**`r > g` is mandatory:** +- The Gordon terminal value `D / (rₑ − g)` is only valid when the cost of equity exceeds the perpetual growth rate. If `g ≥ rₑ` the value is infinite or negative and the model is invalid. Enforce `g < rₑ` and keep `g` at or below long-run nominal GDP growth (preferred 2–4%; the bundled validator flags any `g` above 5%). + +**Formulas Over Hardcodes (NON-NEGOTIABLE):** +- Every projected dividend, discount factor, present value, terminal value, and sensitivity cell MUST be a live Excel formula — never a number computed off-sheet and pasted in. +- The only permitted hardcodes are: (1) raw historical inputs (DPS, EPS, book value), (2) assumption drivers (growth rates, payout, cost-of-equity inputs, terminal `g`), (3) current market data (share price, shares outstanding). +- If you catch yourself computing a value off-sheet and writing the result — STOP. The model must flex when the user changes an assumption. + +**Verify Step-by-Step With the User (do NOT build end-to-end):** +- After data retrieval → show the dividend history, payout trend, and cost-of-equity inputs; confirm before projecting. +- After the dividend projection → show the explicit-period DPS and growth; confirm before the terminal value. +- After the terminal value + PV → show the per-share bridge; confirm before sensitivity tables. +- Catch errors at each stage — a wrong growth or payout assumption found after the sensitivity tables are built means rebuilding everything downstream. + +**Sensitivity Tables:** +- Use an **odd** number of rows and columns (standard 5×5) so there is a true center cell. +- **Center cell = base case:** build the axes so the middle row/column headers equal the model's actual cost of equity and terminal growth; the center cell's output must then equal the model's headline value per share — this is the sanity check. +- Highlight the center cell (medium-blue fill `#BDD7EE`, bold). +- Populate every cell with a full DDM recalculation formula (no placeholders, no linear approximations). +- Primary table: **cost of equity × terminal growth.** Consider a second table on the high-growth rate × high-growth period. + +**Cell Comments:** add a `Source: [System/Document], [Date], [Reference], [URL if applicable]` comment as each hardcoded input is created — include a hyperlink to the filing or data source when available; do not defer to the end. + +**Model Layout Planning:** define all section row positions, write all headers/labels, then write formulas against the locked positions. + +**Validate Before Delivery:** run `python scripts/validate_ddm.py model.xlsx` (and, for standalone openpyxl builds, `recalc.py` from the `xlsx-author` skill). Zero formula errors required. See [TROUBLESHOOTING.md](TROUBLESHOOTING.md). + +**Scenario Blocks:** build separate Bear/Base/Bull assumption blocks. For auditability, prefer a single consolidation column that selects the active case with `=INDEX([Bear:Bull cells],1,$B$6)` (or `OFFSET`) over scattered nested `=IF($B$6=1,[Bear],IF($B$6=2,[Base],[Bull]))` formulas, mirroring dcf-model's case-selector pattern. + +## DDM Process Workflow + +### Step 1: Data Retrieval and Validation +Gather 5+ years of dividend history (DPS), payout ratio, EPS, and — for financials — book value per share and ROE, plus beta, the risk-free rate, and the equity risk premium. +- Confirm dividends are actually paid and covered by earnings (payout < 100%) and/or free cash flow. +- Confirm diluted shares outstanding (check recent buybacks/issuances). +- Sanity-check beta and the payout trend against history. + +### Step 2: Dividend & Payout Analysis +Document historical DPS growth, the payout-ratio trend, and sustainability. For **financials**, derive the sustainable growth rate `g = ROE × retention ratio` (retention = 1 − payout) — see [references/methodology.md](references/methodology.md) → *DDM for financials*. + +### Step 3: Select the Model +Choose single-stage, two-stage, or three-stage / H-model per the guide below and the detailed criteria in the methodology reference. + +### Step 4: Cost of Equity (CAPM) +`rₑ = r_f + β · ERP` (add a size or country-risk premium only if warranted, as separate labelled lines). This is the discount rate for the entire model. **Do not use WACC.** + +### Step 5: Project Dividends +Build the explicit-period DPS, either directly via a growth rate or as `payout × projected EPS`. Show both dollar amounts and the implied growth %. + +### Step 6: Terminal Value +Gordon continuation at the end of the explicit period: `TVₙ = Dₙ₊₁ / (rₑ − g) = Dₙ(1 + g) / (rₑ − g)`. Enforce `g < rₑ`. + +### Step 7: Discount to Present Value +`V₀ = Σₜ Dₜ / (1 + rₑ)ᵗ + TVₙ / (1 + rₑ)ⁿ`. The result is the intrinsic **equity value per share** — compare it directly to the current share price. + +### Step 8: Sensitivity Analysis +Build the cost-of-equity × terminal-growth table (and optionally high-growth rate × period) per the constraints above. + +### Step 9 (Financials): ROE-Driven Build +For banks/insurers, project dividends from `EPS = ROE × book value per share` and `DPS = EPS × payout`, with `g = ROE × retention`. See the methodology reference. + +## Model Selection Guide + +| Model | Use when | Key inputs | +|---|---|---| +| **Single-stage (Gordon)** | Mature company already at a stable, perpetual growth rate | `D₁`, `rₑ`, `g` | +| **Two-stage** | A defined high-growth period, then an abrupt step to stable growth | high-growth rate & years, then `g`, `rₑ` | +| **Three-stage / H-model** | Growth fades gradually from high to mature | initial & long-run growth, fade period, `rₑ` | + +## Correct Patterns +- **Discount at the cost of equity**, applied consistently to the explicit dividends and the terminal value. +- **`g < rₑ`** and terminal `g` at or below long-run nominal GDP growth. +- **Everything sourced:** every hardcoded input carries a source and an as-of date. +- **Value per share compared to the current price**, with a sensitivity band rather than a single false-precision figure. +- **Reconcile** the DDM value against a DCF and trading comps to form a defensible range. + +## Common Mistakes +- **Discounting at WACC instead of the cost of equity** (overstates value). +- **`g ≥ rₑ`**, producing an infinite or negative Gordon value. +- **Terminal growth above long-run GDP** — indefensible in perpetuity. +- **Assuming a payout ratio > 100% in perpetuity** — dividends cannot exceed earnings forever. +- **Ignoring buybacks:** for companies that return most cash via repurchases, a dividend-only DDM understates value — use a total-payout variant (see methodology reference) or a different method. +- **Applying DDM to non-payers or erratic payers** — use `dcf-model` / `comps-analysis` instead. +- **Mixing nominal and real** rates and growth. + +## Excel Model Structure +- **`DDM` sheet:** inputs block (dividend history, payout, market data) → cost-of-equity build (CAPM) → explicit dividend projection → terminal value → PV bridge and value per share. +- **`Sensitivity` sheet (or section):** the cost-of-equity × terminal-growth table with the highlighted center cell. +- Use the `xlsx-author` skill to render a standalone `.xlsx` when no live Excel session is available. + +## Validation +Run the bundled validator before delivery: +``` +python scripts/validate_ddm.py model.xlsx +``` +It checks for formula errors, enforces `cost of equity > terminal growth`, and flags an out-of-range cost of equity, an aggressive terminal growth rate, a terminal value that dominates total value, and a payout ratio above 100%. Exit code `0` = PASS. See [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for common issues. + +## Deliverables +- A live Excel DDM model (formulas throughout) with a sourced assumptions block. +- The intrinsic value per share plus a sensitivity band, compared to the current price. +- A short reconciliation note positioning the DDM value against the DCF/comps range. + +## Workflow Integration +- **→ `dcf-model` / `comps-analysis`:** reconcile the DDM value into a blended valuation range; the CAPM cost-of-equity inputs are the same ones `dcf-model` derives in its WACC step (reuse the cost-of-equity portion only). +- **→ `xlsx-author`:** render the model as a standalone `.xlsx`. + +## Guardrails +- This skill produces **decision-support**, not investment advice. It computes an intrinsic value per share; it does not recommend buying, selling, or transacting. +- **Every output is staged for human sign-off.** State all assumptions explicitly and flag any input that was assumed rather than sourced. +- Do not fabricate dividend, payout, or market data. If a required input is unavailable from an authorised source, say so and present the value as a labelled range under stated assumptions rather than inventing a point estimate. diff --git a/plugins/vertical-plugins/financial-analysis/skills/ddm-model/TROUBLESHOOTING.md b/plugins/vertical-plugins/financial-analysis/skills/ddm-model/TROUBLESHOOTING.md new file mode 100644 index 000000000..480161a20 --- /dev/null +++ b/plugins/vertical-plugins/financial-analysis/skills/ddm-model/TROUBLESHOOTING.md @@ -0,0 +1,36 @@ +# DDM Model Troubleshooting Guide + +**When to read this file:** if `scripts/validate_ddm.py` reports errors, OR the value per share seems unreasonable, OR the model behaves oddly when you switch scenarios. + +## Validator Reports a Critical Error + +### "terminal growth >= cost of equity" +- The Gordon term `D / (rₑ − g)` is infinite or negative. `g` must be strictly less than `rₑ`. +- Fix: lower the perpetual growth `g` (it should be at or below long-run nominal GDP, ~2–4%) or verify the cost-of-equity inputs (`r_f`, `β`, `ERP`). + +### #REF! / #DIV/0! / #VALUE! errors +- `#REF!` — a formula points at a row that moved after headers were inserted. Lock all row positions before writing formulas; rebuild the broken references. +- `#DIV/0!` — usually `rₑ − g` evaluated to zero, or an empty cost-of-equity cell. Confirm the inputs are populated and `rₑ > g`. +- `#VALUE!` — a text value where a number is expected. Verify all inputs are numbers, not strings. + +## Value per Share Seems Unreasonable + +### Implied value far too high +- Check that you are discounting at the **cost of equity, not WACC** (WACC is lower, which inflates value). +- Check `rₑ − g` isn't razor-thin — a tiny spread explodes the terminal value. +- Verify the terminal value isn't >90% of total value (extend the explicit horizon if so). +- Confirm the payout ratio is sustainable (≤100%) and growth isn't above GDP. + +### Implied value far too low +- Check the cost of equity isn't too high (beta or ERP overstated). +- Confirm dividends/payout reflect reality — if the company returns cash mainly via **buybacks**, switch to a total-payout DDM (see references/methodology.md §6); a dividend-only model will understate value. +- Verify the explicit-period growth isn't too conservative. + +## Wrong Method for the Company +- **Non-payer or erratic payer:** the DDM is inappropriate — use `dcf-model` or `comps-analysis`. +- **Bank/insurer:** use the ROE-driven build (references/methodology.md §5); a plain growth-rate DDM ignores the ROE-vs-cost-of-equity spread that drives financial-sector value. + +## Scenario Selector Not Working +- Verify the case selector cell contains 1, 2, or 3. +- Check the `IF` formulas reference the correct Bear/Base/Bull block cells and use absolute references (`$B$6`) for the selector. +- Test by changing the selector manually and confirming the dividend projection and value per share update. diff --git a/plugins/vertical-plugins/financial-analysis/skills/ddm-model/references/methodology.md b/plugins/vertical-plugins/financial-analysis/skills/ddm-model/references/methodology.md new file mode 100644 index 000000000..33d296bc0 --- /dev/null +++ b/plugins/vertical-plugins/financial-analysis/skills/ddm-model/references/methodology.md @@ -0,0 +1,111 @@ +# Dividend Discount Model — Methodology Reference + +Detailed math and modelling guidance for the `ddm-model` skill. Read this before building a multi-stage model or valuing a financial institution. + +All DDM variants share one principle: the intrinsic value of a share is the present value of the dividends it will pay, discounted at the **cost of equity** `rₑ` (never WACC). Every variant requires `rₑ > g` for any perpetual-growth term. + +## Table of Contents +1. [Cost of equity](#1-cost-of-equity) +2. [Single-stage (Gordon growth)](#2-single-stage-gordon-growth) +3. [Two-stage DDM](#3-two-stage-ddm) +4. [Three-stage DDM and the H-model](#4-three-stage-ddm-and-the-h-model) +5. [DDM for financials (ROE-driven)](#5-ddm-for-financials-roe-driven) +6. [Total-payout DDM (buybacks)](#6-total-payout-ddm-buybacks) +7. [Justified multiples cross-check](#7-justified-multiples-cross-check) +8. [Reconciliation with DCF and comps](#8-reconciliation-with-dcf-and-comps) +9. [Worked example (two-stage)](#9-worked-example-two-stage) + +--- + +## 1. Cost of equity +Discount every DDM at the cost of equity from CAPM: +``` +rₑ = r_f + β · ERP (+ size premium, + country-risk premium if warranted) +``` +- `r_f` — risk-free rate, matched to a long horizon (typically the 10Y government yield). +- `β` — levered equity beta of the company (or a peer-derived beta). +- `ERP` — equity risk premium for the relevant market. + +Do **not** use WACC: dividends are cash flows to equity, so the equity discount rate applies. (The `dcf-model` skill derives the same CAPM cost of equity in its WACC step — reuse that, taking the cost-of-equity portion only.) + +## 2. Single-stage (Gordon growth) +For a company already at a stable, perpetual dividend growth rate `g`: +``` +V₀ = D₁ / (rₑ − g) = D₀ (1 + g) / (rₑ − g) requires rₑ > g +``` +Use only when growth is genuinely mature and stable. The value is extremely sensitive to the `rₑ − g` spread — always show a sensitivity table over both. + +## 3. Two-stage DDM +An explicit high-growth phase of `n` years at `g₁`, then a stable perpetual rate `g`: +``` +PV(explicit) = Σₜ₌₁ⁿ D₀ (1 + g₁)ᵗ / (1 + rₑ)ᵗ + +TVₙ = Dₙ₊₁ / (rₑ − g) = D₀ (1 + g₁)ⁿ (1 + g) / (rₑ − g) + +V₀ = PV(explicit) + TVₙ / (1 + rₑ)ⁿ requires rₑ > g +``` +The terminal value is computed **at the end of year `n`** and then discounted `n` periods back. A common error is discounting `TVₙ` by `n+1` periods — it is already an end-of-year-`n` value. + +## 4. Three-stage DDM and the H-model +Three-stage models a high-growth phase, a linear-decline (fade) phase, and a stable phase. The **H-model** is a closed-form approximation of a growth rate that declines *linearly* from an initial `g_S` to a long-run `g_L` over a fade period of `2H` years: +``` +V₀ = [ D₀ (1 + g_L) + D₀ · H · (g_S − g_L) ] / (rₑ − g_L) requires rₑ > g_L +``` +- `H` = half the length of the fade period (e.g. a 10-year linear fade → `H = 5`). +- The first term is the value as if the company grew at `g_L` forever; the second term is the extra value from the above-normal growth during the fade. +- Use the H-model as a fast approximation or a cross-check; build the full three-stage schedule explicitly when precision matters. + +## 5. DDM for financials (ROE-driven) +For banks and insurers, "free cash flow" is ill-defined, so the DDM is the natural method — but drive dividends from profitability rather than a bare growth rate: +``` +retention b = 1 − payout +sustainable growth g = ROE · b +EPSₜ = ROE · BVPSₜ₋₁ +BVPSₜ = BVPSₜ₋₁ + EPSₜ · b (retained earnings build book value) +DPSₜ = EPSₜ · payout +``` +Then discount `DPSₜ` at `rₑ` exactly as in the two-/three-stage models. This links value directly to the spread between ROE and the cost of equity: a bank earning `ROE = rₑ` is worth book value; `ROE > rₑ` justifies a premium to book. Regulatory capital minimums cap how much can be paid out — do not assume a payout that breaches capital requirements. + +## 6. Total-payout DDM (buybacks) +When a company returns most cash through **buybacks**, a dividend-only DDM understates value. Two fixes: +- **Total-payout per share:** replace `DPSₜ` with `(dividends + net buybacks)ₜ / shares`, holding the analysis on a per-share basis. +- **Aggregate then divide:** discount total cash returned to equity, then divide by (declining) shares outstanding. +Be careful that share count falls as buybacks occur; keep the per-share and aggregate views consistent. + +## 7. Justified multiples cross-check +The Gordon model implies "justified" multiples that sanity-check the output against comps: +``` +justified leading P/E = payout / (rₑ − g) +justified P/B = (ROE − g) / (rₑ − g) +``` +If the DDM's implied P/E or P/B is wildly different from where the peer group trades, revisit the assumptions. + +## 8. Reconciliation with DCF and comps +The DDM yields **equity value per share directly** — directly comparable to the current price and to a DCF's equity value per share and the comps-implied price. Present all methods in a range and weight by fit: +- Weight **DDM** highest for financials, REITs, utilities, and mature high-payout names. +- Weight **DCF** (`dcf-model`) highest for non-financial operating companies with meaningful reinvestment. +- Weight **comps** (`comps-analysis`) for a market-relative read. +Always present a range (Bear/Base/Bull), not a point estimate. + +## 9. Worked example (two-stage) +Inputs: current dividend `D₀ = $2.00`; Stage 1 growth `g₁ = 8%` for `n = 5` years; stable growth `g = 3%`; cost of equity `rₑ = 9%`. + +| Year | DPS | PV @ 9% | +|---:|---:|---:| +| 1 | 2.1600 | 1.9817 | +| 2 | 2.3328 | 1.9635 | +| 3 | 2.5194 | 1.9455 | +| 4 | 2.7210 | 1.9276 | +| 5 | 2.9387 | 1.9099 | +| **PV of explicit dividends** | | **9.7281** | + +Terminal value (first stable-phase dividend `D₆ = 2.9387 × 1.03 = 3.0268`): +``` +TV₅ = D₆ / (rₑ − g) = 3.0268 / (0.09 − 0.03) = 50.4469 +PV of TV₅ = 50.4469 / (1.09)⁵ = 32.7870 +``` +Intrinsic value per share: +``` +V₀ = 9.7281 + 32.7870 = $42.52 +``` +The terminal value is **77.1%** of the total — healthy for a 5-year explicit period. If it exceeded ~90%, the explicit horizon would be too short (the bundled `validate_ddm.py` flags this). Compare `$42.52` to the current share price and reconcile against the DCF and comps ranges before drawing any conclusion — and remember every output is staged for human review, not a recommendation. diff --git a/plugins/vertical-plugins/financial-analysis/skills/ddm-model/requirements.txt b/plugins/vertical-plugins/financial-analysis/skills/ddm-model/requirements.txt new file mode 100644 index 000000000..06e1b6557 --- /dev/null +++ b/plugins/vertical-plugins/financial-analysis/skills/ddm-model/requirements.txt @@ -0,0 +1,4 @@ +# DDM Model Builder - Python Dependencies + +# Excel file handling (used by scripts/validate_ddm.py) +openpyxl>=3.0.0 diff --git a/plugins/vertical-plugins/financial-analysis/skills/ddm-model/scripts/validate_ddm.py b/plugins/vertical-plugins/financial-analysis/skills/ddm-model/scripts/validate_ddm.py new file mode 100644 index 000000000..1295b20b6 --- /dev/null +++ b/plugins/vertical-plugins/financial-analysis/skills/ddm-model/scripts/validate_ddm.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +""" +DDM Model Validation Script +Validates Excel Dividend Discount Model (DDM) workbooks for formula errors +and common DDM mistakes. + +Mirrors the conventions of the dcf-model skill's validate_dcf.py so the two +model validators behave and report identically. + +Usage: + python validate_ddm.py [output.json] +""" + +import sys +import json +from pathlib import Path + + +class DDMModelValidator: + """Validates DDM models for errors and quality issues.""" + + def __init__(self, excel_path: str): + try: + import openpyxl + except ImportError: + raise ImportError("openpyxl not installed. Run: pip install openpyxl") + + self.excel_path = excel_path + self.openpyxl = openpyxl + + if not Path(excel_path).exists(): + raise FileNotFoundError(f"File not found: {excel_path}") + + self.workbook_formulas = openpyxl.load_workbook(excel_path, data_only=False) + self.workbook_values = openpyxl.load_workbook(excel_path, data_only=True) + self.errors = [] + self.warnings = [] + self.info = [] + + def validate_all(self) -> dict: + """Run all validation checks and return a results dict.""" + from datetime import datetime + + self.check_sheet_structure() + self.check_formula_errors() + self.check_ddm_logic() + + return { + "file": self.excel_path, + "validation_date": datetime.now().isoformat(), + "status": "PASS" if len(self.errors) == 0 else "FAIL", + "error_count": len(self.errors), + "warning_count": len(self.warnings), + "errors": self.errors, + "warnings": self.warnings, + "info": self.info, + } + + # --- structural checks -------------------------------------------------- + def check_sheet_structure(self): + """Verify recommended sheets exist.""" + recommended = ["DDM", "Sensitivity"] + sheet_names = self.workbook_values.sheetnames + for sheet in recommended: + if sheet not in sheet_names: + self.warnings.append(f"Recommended sheet missing: {sheet}") + else: + self.info.append(f"Found sheet: {sheet}") + + def check_formula_errors(self): + """Check for Excel formula errors across all sheets.""" + excel_errors = ["#VALUE!", "#DIV/0!", "#REF!", "#NAME?", "#NULL!", "#NUM!", "#N/A"] + total_errors = 0 + total_formulas = 0 + + for sheet_name in self.workbook_values.sheetnames: + ws_values = self.workbook_values[sheet_name] + ws_formulas = self.workbook_formulas[sheet_name] + for row in ws_values.iter_rows(): + for cell in row: + formula_cell = ws_formulas[cell.coordinate] + if ( + formula_cell.value + and isinstance(formula_cell.value, str) + and formula_cell.value.startswith("=") + ): + total_formulas += 1 + if cell.value is not None and isinstance(cell.value, str): + for err in excel_errors: + if err in cell.value: + location = f"{sheet_name}!{cell.coordinate}" + self.errors.append(f"{err} at {location}") + total_errors += 1 + break + + self.info.append(f"Total formulas: {total_formulas}") + if total_errors == 0: + self.info.append("✓ No formula errors found") + else: + # summary goes in info so error_count reflects the number of erroring cells + self.info.append(f"Total formula errors: {total_errors}") + + # --- DDM-specific logic ------------------------------------------------- + def check_ddm_logic(self): + self._check_cost_of_equity_vs_growth() + self._check_cost_of_equity_range() + self._check_terminal_growth_reasonable() + self._check_terminal_value_proportion() + self._check_payout_ratio() + + def _find_rate_near_label(self, sheet, label_terms, lo=0.0, hi=1.0, exclude_terms=()): + """Return the first plausible number (lo <= x < hi) adjacent to a cell + whose text contains ALL of label_terms (and NONE of exclude_terms), + case-insensitive. Probes cells to the right of the label first, then + directly below it, so both horizontal and vertical layouts are found.""" + for row in sheet.iter_rows(max_row=200, max_col=25): + for cell in row: + if cell.value and isinstance(cell.value, str): + text = cell.value.lower() + if all(term in text for term in label_terms) and not any( + x in text for x in exclude_terms + ): + for dr, dc in ((0, 1), (0, 2), (0, 3), (0, 4), (0, 5), (1, 0), (2, 0)): + adjacent = sheet.cell(cell.row + dr, cell.column + dc).value + if isinstance(adjacent, (int, float)) and lo <= adjacent < hi: + return adjacent + return None + + def _ddm_sheet(self): + try: + return self.workbook_values["DDM"] + except KeyError: + # fall back to the first sheet so single-sheet models still validate + return self.workbook_values[self.workbook_values.sheetnames[0]] + + def _cost_of_equity(self, sheet): + # accept several common labels for the discount rate in a DDM + for terms in (["cost", "equity"], ["required", "return"], ["discount", "rate"]): + r = self._find_rate_near_label(sheet, terms) + if r is not None: + return r + return None + + def _terminal_growth(self, sheet): + for terms in (["terminal", "growth"], ["perpetual", "growth"], ["long-term", "growth"], ["long", "run", "growth"]): + g = self._find_rate_near_label(sheet, terms) + if g is not None: + return g + return None + + def _check_cost_of_equity_vs_growth(self): + """CRITICAL: cost of equity must exceed the perpetual growth rate.""" + try: + sheet = self._ddm_sheet() + r = self._cost_of_equity(sheet) + g = self._terminal_growth(sheet) + if r is not None and g is not None: + if g >= r: + self.errors.append( + f"CRITICAL: terminal growth ({g:.2%}) >= cost of equity ({r:.2%}). " + "The Gordon terminal value D/(r-g) is infinite or negative and is " + "mathematically invalid." + ) + else: + self.info.append( + f"✓ terminal growth ({g:.2%}) < cost of equity ({r:.2%})" + ) + else: + self.warnings.append("Could not locate cost of equity and terminal growth values") + except Exception as e: # noqa: BLE001 - report, never crash the run + self.warnings.append(f"Could not validate cost of equity vs growth: {e}") + + def _check_cost_of_equity_range(self): + try: + r = self._cost_of_equity(self._ddm_sheet()) + if r is None: + self.warnings.append("Could not locate cost of equity value") + elif r < 0.04 or r > 0.20: + self.warnings.append( + f"Cost of equity ({r:.2%}) is outside the typical 4%-20% range. Verify CAPM inputs." + ) + else: + self.info.append(f"✓ Cost of equity ({r:.2%}) in reasonable range") + except Exception as e: # noqa: BLE001 + self.warnings.append(f"Could not validate cost of equity range: {e}") + + def _check_terminal_growth_reasonable(self): + try: + g = self._terminal_growth(self._ddm_sheet()) + if g is None: + self.warnings.append("Could not locate terminal growth value") + elif g > 0.05: + self.warnings.append( + f"Terminal growth ({g:.2%}) exceeds ~5%. A perpetual rate above long-run " + "nominal GDP growth is generally not defensible." + ) + else: + self.info.append(f"✓ Terminal growth ({g:.2%}) <= 5%") + except Exception as e: # noqa: BLE001 + self.warnings.append(f"Could not validate terminal growth: {e}") + + def _check_terminal_value_proportion(self): + """Warn if the PV of the terminal value dominates total value. + + The PV-of-terminal-value and total-value cells must be on the same + basis (both per-share, or both aggregate). We exclude any candidate + 'total' cell whose label also mentions 'terminal' so the terminal-value + line cannot be mistaken for the total, and if the resulting ratio is not + in (0, 1] we treat the two bases as unreconcilable rather than passing + silently. + """ + try: + sheet = self._ddm_sheet() + tv = self._find_rate_near_label(sheet, ["pv", "terminal"], lo=0, hi=1e15) + total = self._find_rate_near_label( + sheet, ["value", "per", "share"], lo=0, hi=1e15, exclude_terms=("terminal",) + ) + if total is None: + total = self._find_rate_near_label( + sheet, ["intrinsic", "value"], lo=0, hi=1e15, exclude_terms=("terminal",) + ) + if tv is not None and total is not None and total > 0: + proportion = tv / total + if not 0 < proportion <= 1: + self.warnings.append( + "Could not reconcile PV-of-terminal-value and total-value bases " + "(per-share vs aggregate); terminal-value proportion not checked." + ) + elif proportion > 0.90: + self.warnings.append( + f"PV of terminal value is {proportion:.1%} of total value. The model is " + "heavily reliant on the perpetuity — consider a longer explicit horizon." + ) + else: + self.info.append(f"✓ PV of terminal value is {proportion:.1%} of total value") + else: + self.warnings.append("Could not locate terminal value and total value") + except Exception as e: # noqa: BLE001 + self.warnings.append(f"Could not validate terminal value proportion: {e}") + + def _check_payout_ratio(self): + try: + payout = self._find_rate_near_label(self._ddm_sheet(), ["payout"], lo=0, hi=5) + if payout is None: + return # payout is optional; not every DDM surfaces it + if payout > 1.0: + self.warnings.append( + f"Payout ratio ({payout:.1%}) exceeds 100% — dividends above earnings are not " + "sustainable in perpetuity. Verify the terminal payout assumption." + ) + else: + self.info.append(f"✓ Payout ratio ({payout:.1%}) <= 100%") + except Exception as e: # noqa: BLE001 + self.warnings.append(f"Could not validate payout ratio: {e}") + + +def validate_ddm_model(excel_path: str) -> dict: + """Validate a DDM model Excel file and return a results dict.""" + return DDMModelValidator(excel_path).validate_all() + + +def main(): + if len(sys.argv) < 2: + print("Usage: python validate_ddm.py [output.json]") + print("\nValidates a Dividend Discount Model for:") + print(" - Formula errors (#REF!, #DIV/0!, etc.)") + print(" - Cost of equity > terminal growth (critical; Gordon requires r > g)") + print(" - Cost of equity in reasonable range (4-20%)") + print(" - Terminal growth <= ~5% (long-run nominal GDP)") + print(" - PV of terminal value not dominating total value") + print(" - Terminal payout ratio <= 100%") + print("\nReturns JSON with errors, warnings, and info.") + sys.exit(1) + + excel_file = sys.argv[1] + output_file = sys.argv[2] if len(sys.argv) > 2 else None + + try: + results = validate_ddm_model(excel_file) + print(json.dumps(results, indent=2)) + if output_file: + with open(output_file, "w") as f: + json.dump(results, f, indent=2) + sys.exit(0 if results["status"] == "PASS" else 1) + except Exception as e: # noqa: BLE001 + print(json.dumps({"file": excel_file, "status": "ERROR", "error": str(e)}, indent=2)) + sys.exit(1) + + +if __name__ == "__main__": + main()