← Files Public Equity InvestingARCHIVED FILE

skills/dcf-model-builder/scripts/plan_validation.py

18.4 KB · Oct 2, 2026 · 00:03 UTC

↓ Download file

"""Structural validation for dcf-model-builder plans.

This module intentionally does not compute valuation outputs. The model engine
adds final computed WACC/terminal-value validation after this structural pass.
"""

from __future__ import annotations

import math
from datetime import datetime
from typing import Any

ALLOWED_LABELS = {
    "reported",
    "company_guidance",
    "consensus",
    "management_case",
    "user_provided",
    "connected_app",
    "web_research",
    "analyst_estimate",
    "placeholder",
    "derived",
}

REQUIRED_SOURCE_TOPICS = {
    "historicals",
    "forecast",
    "wacc",
    "terminal_value",
    "share_count",
    "net_debt",
}

REQUIRED_TOP_LEVEL = [
    "meta",
    "source_basis",
    "timeline",
    "historicals",
    "forecast",
    "wacc",
    "terminal_value",
    "ev_to_equity_bridge",
    "scenarios",
    "sensitivities",
]

SCENARIOS = ["base", "downside", "upside"]


def _is_number(value: Any) -> bool:
    return (
        isinstance(value, (int, float))
        and not isinstance(value, bool)
        and math.isfinite(float(value))
    )


def _date_ok(value: Any) -> bool:
    if not isinstance(value, str):
        return False
    try:
        datetime.strptime(value, "%Y-%m-%d")
        return True
    except ValueError:
        return False


def _date(value: str) -> datetime | None:
    try:
        return datetime.strptime(value, "%Y-%m-%d")
    except Exception:
        return None


def _num(value: Any, default: float = 0.0) -> float:
    if _is_number(value):
        return float(value)
    return float(default)


def _get(plan: dict[str, Any], dotted: str, default: Any = None) -> Any:
    node: Any = plan
    for part in dotted.split("."):
        if not isinstance(node, dict) or part not in node:
            return default
        node = node[part]
    return node


def _validate_vector(
    errors: list[str],
    value: Any,
    path: str,
    horizon: int,
    min_value: float | None = None,
    max_value: float | None = None,
    allow_scalar: bool = True,
) -> None:
    if isinstance(value, list):
        if len(value) != horizon:
            errors.append(f"{path} must contain exactly {horizon} values; found {len(value)}")
            return
        values = value
    elif allow_scalar and _is_number(value):
        values = [value]
    else:
        errors.append(f"{path} must be a number or list of numbers")
        return
    for idx, item in enumerate(values):
        loc = f"{path}[{idx}]" if isinstance(value, list) else path
        if not _is_number(item):
            errors.append(f"{loc} must be numeric")
            continue
        f = float(item)
        if min_value is not None and f < min_value:
            errors.append(f"{loc} must be >= {min_value}")
        if max_value is not None and f > max_value:
            errors.append(f"{loc} must be <= {max_value}")


def _validate_meta(errors: list[str], plan: dict[str, Any]) -> datetime | None:
    meta = plan.get("meta", {})
    for field in [
        "company",
        "industry",
        "currency",
        "units",
        "valuation_date",
        "as_of_date",
        "accounting_basis",
        "valuation_purpose",
        "model_type",
    ]:
        if not meta.get(field):
            errors.append(f"meta.{field} is required")
    if meta.get("model_type") not in {"fcff", "fcfe"}:
        errors.append("meta.model_type must be 'fcff' or 'fcfe'")
    for field in ["valuation_date", "as_of_date"]:
        if meta.get(field) and not _date_ok(meta[field]):
            errors.append(f"meta.{field} must be YYYY-MM-DD")
    return _date(str(meta.get("valuation_date", ""))) or _date(str(meta.get("as_of_date", "")))


def _validate_sources(
    errors: list[str], plan: dict[str, Any], model_cutoff_date: datetime | None
) -> set[str]:
    source_basis = plan.get("source_basis")
    source_ids: set[str] = set()
    if not isinstance(source_basis, list) or not source_basis:
        errors.append("source_basis must be a non-empty list")
        return source_ids

    topics = set()
    for idx, src in enumerate(source_basis):
        if not isinstance(src, dict):
            errors.append(f"source_basis[{idx}] must be an object")
            continue
        for field in [
            "id",
            "topic",
            "label",
            "source_name",
            "source_type",
            "as_of_date",
            "confidence",
            "notes",
        ]:
            if field not in src or src.get(field) in (None, ""):
                errors.append(f"source_basis[{idx}].{field} is required")
        sid = str(src.get("id", ""))
        if sid:
            if sid in source_ids:
                errors.append(f"source_basis id '{sid}' is duplicated")
            source_ids.add(sid)
        if src.get("topic"):
            topics.add(str(src.get("topic")))
        if src.get("label") and src.get("label") not in ALLOWED_LABELS:
            errors.append(f"source_basis[{idx}].label '{src.get('label')}' is not allowed")
        if src.get("confidence") and src.get("confidence") not in {
            "high",
            "medium",
            "low",
        }:
            errors.append(f"source_basis[{idx}].confidence must be high, medium, or low")
        if src.get("as_of_date") and not _date_ok(src["as_of_date"]):
            errors.append(f"source_basis[{idx}].as_of_date must be YYYY-MM-DD")
        source_as_of_date = _date(str(src.get("as_of_date", "")))
        if model_cutoff_date and source_as_of_date and source_as_of_date > model_cutoff_date:
            errors.append(f"source_basis[{idx}].as_of_date cannot be after meta.valuation_date")

    missing_topics = sorted(REQUIRED_SOURCE_TOPICS - topics)
    if missing_topics:
        errors.append("source_basis missing required material topics: " + ", ".join(missing_topics))
    return source_ids


def _validate_timeline(errors: list[str], plan: dict[str, Any]) -> int:
    timeline = plan.get("timeline", {})
    horizon = timeline.get("horizon_years")
    if not isinstance(timeline.get("start_year"), int):
        errors.append("timeline.start_year must be an integer")
    if not isinstance(horizon, int) or horizon < 1 or horizon > 15:
        errors.append("timeline.horizon_years must be an integer from 1 to 15")
        horizon = 0
    if timeline.get("periodicity") not in {"annual", "quarterly"}:
        errors.append("timeline.periodicity must be 'annual' or 'quarterly'")
    return int(horizon or 0)


def _validate_historicals(errors: list[str], plan: dict[str, Any]) -> None:
    historicals = plan.get("historicals", {})
    hist_required = [
        "latest_year",
        "revenue",
        "ebitda",
        "ebit",
        "cash_taxes",
        "da",
        "capex",
        "change_nwc",
        "net_working_capital",
        "unlevered_fcf",
        "source_id",
    ]
    for field in hist_required:
        if field not in historicals:
            errors.append(f"historicals.{field} is required")
    numeric_fields = [
        "revenue",
        "ebitda",
        "ebit",
        "cash_taxes",
        "da",
        "capex",
        "change_nwc",
        "net_working_capital",
        "unlevered_fcf",
    ]
    for field in numeric_fields:
        if field in historicals and not _is_number(historicals[field]):
            errors.append(f"historicals.{field} must be numeric")
    if (
        "revenue" in historicals
        and _is_number(historicals.get("revenue"))
        and historicals["revenue"] <= 0
    ):
        errors.append("historicals.revenue must be greater than zero")
    if plan.get("meta", {}).get("model_type") == "fcfe" and "net_income" not in historicals:
        errors.append("historicals.net_income is required for FCFE models")


def _validate_source_references(
    errors: list[str], plan: dict[str, Any], source_ids: set[str]
) -> None:
    paths = [
        "historicals.source_id",
        "forecast.source_id",
        "wacc.source_id",
        "terminal_value.source_id",
        "ev_to_equity_bridge.net_debt_source_id",
        "ev_to_equity_bridge.share_count_source_id",
    ]
    for path in paths:
        sid = _get(plan, path)
        if sid and str(sid) not in source_ids:
            errors.append(f"{path} references unknown source id '{sid}'")


def _validate_forecast(errors: list[str], plan: dict[str, Any]) -> None:
    forecast = plan.get("forecast", {})
    meta = plan.get("meta", {})
    if forecast.get("cash_flow_basis") not in {"fcff", "fcfe"}:
        errors.append("forecast.cash_flow_basis must be 'fcff' or 'fcfe'")
    if (
        forecast.get("cash_flow_basis")
        and meta.get("model_type")
        and forecast.get("cash_flow_basis") != meta.get("model_type")
    ):
        errors.append("forecast.cash_flow_basis must match meta.model_type")
    if not isinstance(forecast.get("mid_year_convention"), bool):
        errors.append("forecast.mid_year_convention must be true or false")


def _validate_wacc(errors: list[str], plan: dict[str, Any]) -> None:
    wacc = plan.get("wacc", {})
    required = [
        "risk_free_rate",
        "beta",
        "equity_risk_premium",
        "size_premium",
        "pre_tax_cost_of_debt",
        "marginal_tax_rate",
        "target_debt_pct",
        "target_equity_pct",
        "source_id",
    ]
    for field in required:
        if field not in wacc:
            errors.append(f"wacc.{field} is required")
    range_checks = {
        "risk_free_rate": (-0.02, 0.20),
        "beta": (0.0, 5.0),
        "equity_risk_premium": (0.0, 0.20),
        "size_premium": (0.0, 0.15),
        "company_specific_premium": (-0.05, 0.25),
        "country_risk_premium": (0.0, 0.30),
        "pre_tax_cost_of_debt": (0.0, 0.40),
        "marginal_tax_rate": (0.0, 0.60),
        "target_debt_pct": (0.0, 1.0),
        "target_equity_pct": (0.0, 1.0),
    }
    for field, (low, high) in range_checks.items():
        if field not in wacc:
            continue
        if not _is_number(wacc[field]):
            errors.append(f"wacc.{field} must be numeric")
            continue
        value = float(wacc[field])
        if value < low or value > high:
            errors.append(f"wacc.{field} must be between {low} and {high}")
    if _is_number(wacc.get("target_debt_pct")) and _is_number(wacc.get("target_equity_pct")):
        preferred = _num(wacc.get("preferred_pct"), 0.0)
        total_weight = float(wacc["target_debt_pct"]) + float(wacc["target_equity_pct"]) + preferred
        if abs(total_weight - 1.0) > 0.02:
            errors.append(
                f"wacc capital structure weights must sum to approximately 1.0; found {total_weight:.3f}"
            )


def _validate_terminal_value(errors: list[str], plan: dict[str, Any]) -> str | None:
    terminal = plan.get("terminal_value", {})
    method = terminal.get("method")
    if method not in {"perpetual_growth", "exit_multiple"}:
        errors.append("terminal_value.method must be 'perpetual_growth' or 'exit_multiple'")
    if method == "perpetual_growth":
        if not _is_number(terminal.get("perpetual_growth_rate")):
            errors.append(
                "terminal_value.perpetual_growth_rate is required and numeric for perpetual growth"
            )
        else:
            growth = float(terminal["perpetual_growth_rate"])
            if growth < -0.05 or growth > 0.08:
                errors.append("terminal_value.perpetual_growth_rate must be between -5% and 8%")
    if method == "exit_multiple" and not _is_number(terminal.get("exit_ebitda_multiple")):
        errors.append(
            "terminal_value.exit_ebitda_multiple is required and numeric for exit multiple"
        )
    if (
        method == "exit_multiple"
        and _is_number(terminal.get("exit_ebitda_multiple"))
        and float(terminal["exit_ebitda_multiple"]) <= 0
    ):
        errors.append("terminal_value.exit_ebitda_multiple must be positive")
    if "exit_ebitda_multiple" in terminal and _is_number(terminal.get("exit_ebitda_multiple")):
        multiple = float(terminal["exit_ebitda_multiple"])
        if multiple <= 0 or multiple > 100:
            errors.append(
                "terminal_value.exit_ebitda_multiple must be positive and less than or equal to 100x"
            )
    return method if isinstance(method, str) else None


def _validate_bridge(errors: list[str], plan: dict[str, Any]) -> None:
    bridge = plan.get("ev_to_equity_bridge", {})
    required = [
        "cash",
        "debt",
        "leases",
        "minorities",
        "associates",
        "pensions",
        "preferred_stock",
        "non_operating_assets",
        "options",
        "other_debt_like_items",
        "diluted_shares",
        "net_debt_source_id",
        "share_count_source_id",
    ]
    for field in required:
        if field not in bridge:
            errors.append(f"ev_to_equity_bridge.{field} is required")
    for field in required:
        if field.endswith("source_id"):
            continue
        if field in bridge and not _is_number(bridge[field]):
            errors.append(f"ev_to_equity_bridge.{field} must be numeric")
    if _is_number(bridge.get("diluted_shares")) and float(bridge["diluted_shares"]) <= 0:
        errors.append("ev_to_equity_bridge.diluted_shares must be greater than zero")


def _validate_scenarios(
    errors: list[str], plan: dict[str, Any], horizon: int, method: str | None
) -> None:
    scenarios = plan.get("scenarios", {})
    forecast = plan.get("forecast", {})
    if not isinstance(scenarios, dict):
        errors.append("scenarios must be an object")
        return
    for name in SCENARIOS:
        if name not in scenarios:
            errors.append(f"scenarios.{name} is required")
            continue
        scenario = scenarios[name]
        if not isinstance(scenario, dict):
            errors.append(f"scenarios.{name} must be an object")
            continue
        if not scenario.get("description"):
            errors.append(f"scenarios.{name}.description is required")
        if horizon:
            _validate_vector(
                errors,
                scenario.get("revenue_growth"),
                f"scenarios.{name}.revenue_growth",
                horizon,
                -0.80,
                2.00,
            )
            _validate_vector(
                errors,
                scenario.get("ebit_margin"),
                f"scenarios.{name}.ebit_margin",
                horizon,
                -0.50,
                0.80,
            )
            _validate_vector(
                errors,
                scenario.get("tax_rate"),
                f"scenarios.{name}.tax_rate",
                horizon,
                0.0,
                0.60,
            )
            _validate_vector(
                errors,
                scenario.get("da_percent_revenue"),
                f"scenarios.{name}.da_percent_revenue",
                horizon,
                0.0,
                1.00,
            )
            _validate_vector(
                errors,
                scenario.get("capex_percent_revenue"),
                f"scenarios.{name}.capex_percent_revenue",
                horizon,
                0.0,
                1.50,
            )
            _validate_vector(
                errors,
                scenario.get("nwc_percent_revenue"),
                f"scenarios.{name}.nwc_percent_revenue",
                horizon,
                -1.0,
                1.0,
            )
            if forecast.get("cash_flow_basis") == "fcfe":
                _validate_vector(
                    errors,
                    scenario.get("net_income_margin"),
                    f"scenarios.{name}.net_income_margin",
                    horizon,
                    -0.50,
                    0.80,
                )
                _validate_vector(
                    errors,
                    scenario.get("net_borrowing", 0.0),
                    f"scenarios.{name}.net_borrowing",
                    horizon,
                    -1e9,
                    1e9,
                )
        if not _is_number(scenario.get("wacc_adjustment")):
            errors.append(f"scenarios.{name}.wacc_adjustment must be numeric")
        if method == "perpetual_growth":
            if not _is_number(scenario.get("terminal_growth_rate")):
                errors.append(f"scenarios.{name}.terminal_growth_rate is required and numeric")
            else:
                terminal_growth = float(scenario["terminal_growth_rate"])
                if terminal_growth < -0.05 or terminal_growth > 0.08:
                    errors.append(
                        f"scenarios.{name}.terminal_growth_rate must be between -5% and 8%"
                    )
        if scenario.get("exit_ebitda_multiple") is not None and not _is_number(
            scenario.get("exit_ebitda_multiple")
        ):
            errors.append(f"scenarios.{name}.exit_ebitda_multiple must be numeric when supplied")


def _validate_sensitivities(errors: list[str], plan: dict[str, Any]) -> None:
    sensitivities = plan.get("sensitivities", {})
    fields = [
        "wacc_delta",
        "terminal_growth_delta",
        "exit_multiple_delta",
        "revenue_growth_delta",
        "ebit_margin_delta",
    ]
    for field in fields:
        value = sensitivities.get(field)
        if not isinstance(value, list) or not value:
            errors.append(f"sensitivities.{field} must be a non-empty list")
            continue
        if 0.0 not in [float(v) for v in value if _is_number(v)]:
            errors.append(f"sensitivities.{field} should include 0.0")
        for idx, item in enumerate(value):
            if not _is_number(item):
                errors.append(f"sensitivities.{field}[{idx}] must be numeric")


def validate_plan_structure_without_computed_checks(plan: dict[str, Any]) -> list[str]:
    """Return actionable structural validation errors. Does not compute valuation outputs."""
    errors: list[str] = []
    if not isinstance(plan, dict):
        return ["plan must be a JSON object"]

    for key in REQUIRED_TOP_LEVEL:
        if key not in plan:
            errors.append(f"missing top-level field: {key}")
    if errors:
        return errors

    model_cutoff_date = _validate_meta(errors, plan)
    source_ids = _validate_sources(errors, plan, model_cutoff_date)
    horizon = _validate_timeline(errors, plan)
    _validate_historicals(errors, plan)
    _validate_source_references(errors, plan, source_ids)
    _validate_forecast(errors, plan)
    _validate_wacc(errors, plan)
    method = _validate_terminal_value(errors, plan)
    _validate_bridge(errors, plan)
    _validate_scenarios(errors, plan, horizon, method)
    _validate_sensitivities(errors, plan)
    return errors

SHA-256: 54d730292e9e9124211146d3401fc0911d2930a7d5fe989c1fa3ae21260b6a2f