← Files Public Equity InvestingARCHIVED FILE

skills/comps-valuation/scripts/audit_comps_workbook.py

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

↓ Download file

#!/usr/bin/env python3
"""Audit a comparable company analysis workbook for structural and formula issues.

Usage:
    python audit_comps_workbook.py model.xlsx --json-out audit.json --markdown-out audit.md
"""

from __future__ import annotations

import argparse
import json
import re
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

EXPECTED_SHEET_GROUPS = {
    "control": ["control", "input", "assumption"],
    "universe": ["universe", "peer", "comp"],
    "market_data": ["market", "price", "ev", "capitalization"],
    "financials": ["financial", "statement", "estimate", "consensus"],
    "adjustments": ["adjust", "normal", "calendar"],
    "multiples": ["multiple", "valuation metric"],
    "benchmarking": ["benchmark", "kpi", "operating"],
    "valuation": ["valuation", "output", "summary"],
    "sources": ["source", "citation", "footnote"],
    "qa": ["qa", "check", "audit"],
}

ERROR_PATTERNS = ("#REF!", "#DIV/0!", "#VALUE!", "#NAME?", "#NUM!", "#N/A")
SOURCE_TERMS = (
    "source",
    "retrieval",
    "as of",
    "data date",
    "valuation date",
    "filing",
    "accession",
    "url",
)


def normalize(name: str) -> str:
    return re.sub(r"[^a-z0-9]+", " ", name.lower()).strip()


def sheet_matches(sheet_name: str, terms: list[str]) -> bool:
    normalized = normalize(sheet_name)
    return any(term in normalized for term in terms)


def find_sheet_groups(sheet_names: list[str]) -> dict[str, list[str]]:
    matches: dict[str, list[str]] = {}
    for group, terms in EXPECTED_SHEET_GROUPS.items():
        matches[group] = [name for name in sheet_names if sheet_matches(name, terms)]
    return matches


def used_cells(ws) -> list[Any]:
    return [cell for row in ws.iter_rows() for cell in row if cell.value not in (None, "")]


def audit_workbook(path: Path) -> dict[str, Any]:
    try:
        from openpyxl import load_workbook
    except ImportError as exc:  # pragma: no cover
        raise SystemExit("openpyxl is required. Install with: pip install openpyxl") from exc

    wb = load_workbook(path, data_only=False, read_only=False)
    sheet_names = wb.sheetnames
    groups = find_sheet_groups(sheet_names)

    findings: list[dict[str, str]] = []
    metrics: dict[str, Any] = {
        "workbook": str(path),
        "audited_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
        "sheet_count": len(sheet_names),
        "sheets": sheet_names,
        "matched_sheet_groups": groups,
    }

    missing_groups = [group for group, names in groups.items() if not names]
    if missing_groups:
        findings.append(
            {
                "severity": "warning",
                "category": "structure",
                "finding": "Missing or unclear expected sheet groups: " + ", ".join(missing_groups),
                "recommendation": "Add or clearly label tabs for these functions, or document where they are handled.",
            }
        )

    formula_counts = Counter()
    hardcode_counts = Counter()
    error_cells: list[str] = []
    external_formula_cells: list[str] = []
    formula_by_sheet: dict[str, int] = {}
    numeric_by_sheet = Counter()
    comments_by_sheet: dict[str, int] = {}
    source_term_hits: list[str] = []

    ticker_values: list[str] = []
    ticker_headers_seen = False

    for ws in wb.worksheets:
        cells = used_cells(ws)
        formula_count = 0
        numeric_count = 0
        comments_count = 0
        for cell in cells:
            value = cell.value
            coord = f"{ws.title}!{cell.coordinate}"
            if cell.comment:
                comments_count += 1
            if isinstance(value, str):
                lower = value.lower()
                if any(term in lower for term in SOURCE_TERMS):
                    source_term_hits.append(coord)
                if value.startswith("="):
                    formula_count += 1
                    formula_counts[ws.title] += 1
                    if any(pattern in value.upper() for pattern in ERROR_PATTERNS):
                        error_cells.append(coord)
                    if "[" in value and "]" in value:
                        external_formula_cells.append(coord)
                elif any(pattern in value.upper() for pattern in ERROR_PATTERNS):
                    error_cells.append(coord)
            elif isinstance(value, (int, float)):
                numeric_count += 1
                numeric_by_sheet[ws.title] += 1
                if (
                    any(
                        key in normalize(ws.title)
                        for key in ("valuation", "output", "summary", "multiple")
                    )
                    and cell.row > 3
                ):
                    hardcode_counts[ws.title] += 1
            if isinstance(value, str) and normalize(value) in ("ticker", "tickers"):
                ticker_headers_seen = True
                # collect values under this header in the same column
                for r in range(cell.row + 1, min(ws.max_row, cell.row + 60) + 1):
                    maybe = ws.cell(r, cell.column).value
                    if isinstance(maybe, str) and maybe.strip() and not maybe.startswith("="):
                        ticker_values.append(maybe.strip().upper())
        formula_by_sheet[ws.title] = formula_count
        numeric_by_sheet[ws.title] = numeric_count
        comments_by_sheet[ws.title] = comments_count

        if any(key in normalize(ws.title) for key in ("valuation", "output", "summary")):
            if formula_count == 0 and numeric_count > 5:
                findings.append(
                    {
                        "severity": "warning",
                        "category": "formula_integrity",
                        "finding": f"{ws.title} has numeric values but no formulas detected.",
                        "recommendation": "Review whether valuation outputs are hardcoded instead of linked to assumptions and data tabs.",
                    }
                )

    metrics["formula_counts_by_sheet"] = formula_by_sheet
    metrics["numeric_counts_by_sheet"] = dict(numeric_by_sheet)
    metrics["comments_by_sheet"] = comments_by_sheet
    metrics["external_formula_cells"] = external_formula_cells[:100]
    metrics["error_cells"] = error_cells[:100]
    metrics["hardcode_counts_on_output_like_sheets"] = dict(hardcode_counts)

    if error_cells:
        findings.append(
            {
                "severity": "critical",
                "category": "formula_integrity",
                "finding": f"Detected formula or cell error patterns in {len(error_cells)} cells.",
                "recommendation": "Inspect and fix error cells before senior review. Do not rely on affected outputs.",
            }
        )

    if external_formula_cells:
        findings.append(
            {
                "severity": "warning",
                "category": "formula_integrity",
                "finding": f"Detected external workbook references in {len(external_formula_cells)} formula cells.",
                "recommendation": "Break, document, or preserve external links intentionally. Final deliverables should not depend on hidden external files unless requested.",
            }
        )

    if hardcode_counts:
        findings.append(
            {
                "severity": "warning",
                "category": "formula_integrity",
                "finding": "Output-like sheets contain numeric hardcodes: "
                + ", ".join(f"{k}={v}" for k, v in hardcode_counts.items()),
                "recommendation": "Confirm these are labeled assumptions. Replace hidden hardcoded outputs with formulas where possible.",
            }
        )

    if not source_term_hits:
        findings.append(
            {
                "severity": "warning",
                "category": "sources",
                "finding": "No obvious source/date terminology detected in workbook cells.",
                "recommendation": "Add a Sources tab with source name, retrieval date, data date, metric, period, and confidence label.",
            }
        )

    if ticker_headers_seen:
        dupes = [ticker for ticker, count in Counter(ticker_values).items() if count > 1]
        if dupes:
            findings.append(
                {
                    "severity": "warning",
                    "category": "peer_universe",
                    "finding": "Duplicate ticker values detected: " + ", ".join(dupes[:20]),
                    "recommendation": "Check for duplicate peers, repeated target rows, or accidental copy/paste errors.",
                }
            )
    else:
        findings.append(
            {
                "severity": "info",
                "category": "peer_universe",
                "finding": "No explicit ticker header detected.",
                "recommendation": "Ensure the peer universe has a clear ticker/company identifier column.",
            }
        )

    # Check whether source and QA tabs have meaningful comments/logs.
    comments_total = sum(comments_by_sheet.values())
    if comments_total == 0:
        findings.append(
            {
                "severity": "info",
                "category": "documentation",
                "finding": "No cell comments detected.",
                "recommendation": "Consider adding comments to major assumptions and sourced inputs for auditability.",
            }
        )

    # Scoring: start at 100 and subtract by severity.
    score = 100
    penalty = {"critical": 25, "warning": 10, "info": 3}
    for item in findings:
        score -= penalty.get(item["severity"], 0)
    metrics["quality_score_directional"] = max(score, 0)

    return {"metrics": metrics, "findings": findings}


def to_markdown(report: dict[str, Any]) -> str:
    metrics = report["metrics"]
    findings = report["findings"]
    by_severity = defaultdict(list)
    for item in findings:
        by_severity[item["severity"]].append(item)

    lines = [
        "# Comps Workbook Audit",
        "",
        f"Workbook: `{metrics['workbook']}`",
        f"Audited at: {metrics['audited_at']}",
        f"Directional quality score: {metrics['quality_score_directional']}/100",
        "",
        "## Sheet coverage",
        "",
    ]
    for group, sheets in metrics["matched_sheet_groups"].items():
        status = ", ".join(sheets) if sheets else "missing/unclear"
        lines.append(f"- {group}: {status}")
    lines.extend(["", "## Findings", ""])
    if not findings:
        lines.append(
            "No issues detected by automated checks. Manual MD/PM review is still required."
        )
    else:
        for severity in ("critical", "warning", "info"):
            if severity in by_severity:
                lines.append(f"### {severity.title()}")
                lines.append("")
                for item in by_severity[severity]:
                    lines.append(
                        f"- **{item['category']}**: {item['finding']} Recommendation: {item['recommendation']}"
                    )
                lines.append("")
    lines.extend(
        [
            "## Notes",
            "",
            "This automated audit checks structure, formulas, external links, obvious hardcodes, source terminology, comments, and duplicate tickers. It does not replace manual peer selection, normalization, or valuation judgment review.",
        ]
    )
    return "\n".join(lines)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Audit a comps workbook for structural and formula issues."
    )
    parser.add_argument("workbook", help="Path to .xlsx workbook.")
    parser.add_argument("--json-out", help="Optional path for JSON audit output.")
    parser.add_argument("--markdown-out", help="Optional path for Markdown audit output.")
    parser.add_argument(
        "--run-log", help="Optional path for run_log.json. Defaults beside the first file output."
    )
    return parser.parse_args()


def default_run_log_path(args: argparse.Namespace) -> Path | None:
    if args.run_log:
        return Path(args.run_log).resolve()
    if args.json_out:
        return Path(args.json_out).resolve().parent / "run_log.json"
    if args.markdown_out:
        return Path(args.markdown_out).resolve().parent / "run_log.json"
    return None


def output_paths(args: argparse.Namespace, run_log_path: Path | None) -> dict[str, str]:
    paths: dict[str, str] = {}
    if args.json_out:
        paths["json_report"] = str(Path(args.json_out).resolve())
    if args.markdown_out:
        paths["markdown_report"] = str(Path(args.markdown_out).resolve())
    if run_log_path:
        paths["run_log"] = str(run_log_path)
        paths["manifest"] = str(run_log_path.parent / "manifest.json")
    return paths


def output_manifest(paths: dict[str, str]) -> list[dict[str, Any]]:
    return [
        {
            "key": key,
            "path": artifact_path,
            "required": key in {"run_log", "manifest"},
            "written": key in {"run_log", "manifest"} or Path(artifact_path).exists(),
            "description": "Comps workbook audit artifact.",
            "artifact_role": "machine_support"
            if key in {"run_log", "manifest"}
            else "support_artifact",
            "hidden_unless_requested": True,
            "user_visible_default": False,
            "support_reason": "Audit JSON/Markdown/logs are support artifacts; do not present them as the primary comps deliverable.",
        }
        for key, artifact_path in paths.items()
    ]


def write_run_log(
    run_log_path: Path | None,
    *,
    status: str,
    workbook: Path,
    outputs: dict[str, str],
    warnings: list[str],
    hard_failures: list[str],
    dependency_status: dict[str, str],
    audit_report: dict[str, Any] | None = None,
) -> None:
    if run_log_path is None:
        return
    run_log_path.parent.mkdir(parents=True, exist_ok=True)
    manifest = output_manifest(outputs)
    payload = {
        "status": status,
        "model_status": "not-decision-ready" if hard_failures else "senior-review-ready",
        "artifact_level": "audit_export",
        "workbook_mode": "existing_workbook_audit",
        "artifact_mode": "support_only",
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "workbook": str(workbook),
        "source_basis": [],
        "warnings": warnings,
        "hard_failures": hard_failures,
        "dependency_status": dependency_status,
        "outputs": outputs,
        "primary_human_deliverable": None,
        "support_artifacts_user_visible_default": False,
        "final_response_guidance": {
            "lead_with": "source_workbook_plus_audit_summary",
            "mention_support_artifacts": "only_briefly_unless_requested",
        },
        "output_manifest": manifest,
        "audit_summary": {
            "finding_count": len(audit_report.get("findings", [])) if audit_report else 0,
            "quality_score_directional": audit_report.get("metrics", {}).get(
                "quality_score_directional"
            )
            if audit_report
            else None,
        },
    }
    run_log_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
    (run_log_path.parent / "manifest.json").write_text(
        json.dumps(
            {
                "primary_human_deliverable": None,
                "artifact_mode": "support_only",
                "support_artifacts_user_visible_default": False,
                "final_response_guidance": payload["final_response_guidance"],
                "outputs": manifest,
            },
            indent=2,
        )
        + "\n",
        encoding="utf-8",
    )


def main() -> int:
    args = parse_args()
    path = Path(args.workbook).resolve()
    run_log_path = default_run_log_path(args)
    outputs = output_paths(args, run_log_path)
    if not path.exists():
        failure = f"Workbook not found: {path}"
        write_run_log(
            run_log_path,
            status="failed",
            workbook=path,
            outputs=outputs,
            warnings=[],
            hard_failures=[failure],
            dependency_status={"openpyxl": "not_checked"},
        )
        print(failure)
        return 1
    try:
        report = audit_workbook(path)
        if args.json_out:
            out = Path(args.json_out)
            out.parent.mkdir(parents=True, exist_ok=True)
            out.write_text(json.dumps(report, indent=2), encoding="utf-8")
        if args.markdown_out:
            out = Path(args.markdown_out)
            out.parent.mkdir(parents=True, exist_ok=True)
            out.write_text(to_markdown(report), encoding="utf-8")
    except SystemExit as exc:
        failure = str(exc)
        write_run_log(
            run_log_path,
            status="failed",
            workbook=path,
            outputs=outputs,
            warnings=[],
            hard_failures=[failure],
            dependency_status={
                "openpyxl": "missing" if "openpyxl is required" in failure else "unknown"
            },
        )
        print(f"ERROR: {failure}")
        return 1
    except Exception as exc:
        failure = str(exc)
        write_run_log(
            run_log_path,
            status="failed",
            workbook=path,
            outputs=outputs,
            warnings=[],
            hard_failures=[failure],
            dependency_status={
                "openpyxl": "missing" if "openpyxl is required" in failure else "unknown"
            },
        )
        print(f"ERROR: {failure}")
        return 1
    warnings = [
        item["finding"]
        for item in report.get("findings", [])
        if item.get("severity") in {"warning", "critical"}
    ]
    write_run_log(
        run_log_path,
        status="completed",
        workbook=path,
        outputs=outputs,
        warnings=warnings,
        hard_failures=[],
        dependency_status={"openpyxl": "available"},
        audit_report=report,
    )
    print(to_markdown(report))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 918ce60c69af3740b2e5f8fa3ffbac56bcc6b6dff611b531c72f73d3651d1660