← Files Public Equity InvestingARCHIVED FILE

skills/earnings-deep-dive/scripts/validate_normalized_inputs.py

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

↓ Download file

#!/usr/bin/env python3
"""Validate normalized CSV inputs (metrics/estimates/guidance/quotes/driver updates).

Rules enforced:
- Required columns exist
- Non-empty rows for required deliverables
- For any non-MISSING metric value, SourceTag must be present and not MISSING
- Non-GAAP rows must include comparable GAAP metric + reconciliation source

Exit codes:
  0 = valid (may still have warnings)
  1 = errors found
"""

from __future__ import annotations

import sys
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

if __name__ == "__main__" and any(arg in {"-h", "--help"} for arg in sys.argv[1:]):
    print("Usage: python scripts/validate_normalized_inputs.py plan.json")
    print("Validate normalized CSV inputs for the earnings deep-dive pipeline.")
    raise SystemExit(0)

import pandas as pd

try:
    from .utils.io_utils import ensure_dir, read_json, write_text
    from .utils.validation_utils import as_float_or_none, is_missing, require_columns
except ImportError:
    from utils.io_utils import ensure_dir, read_json, write_text
    from utils.validation_utils import as_float_or_none, is_missing, require_columns


REQ_METRICS = [
    "MetricName",
    "Period",
    "Value",
    "Units",
    "GAAP_Flag",
    "Segment",
    "IsTearSheet",
    "DisplayOrder",
    "SourceTag",
]

REQ_ESTIMATES = [
    "MetricName",
    "Period",
    "EstimateType",
    "Value",
    "Units",
    "AsOf",
    "Source",
]

REQ_GUIDANCE = [
    "MetricName",
    "Period",
    "Low",
    "High",
    "Units",
    "GAAP_Flag",
    "SourceTag",
]

REQ_QUOTES = [
    "Section",
    "Speaker",
    "Questioner",
    "TopicTag",
    "QuoteText",
    "SourceTag",
]

REQ_DRIVER_UPDATES = [
    "DriverID",
    "Period",
    "NewValue",
    "Units",
    "Why",
    "SourceTag",
]


def _read_csv(path: str) -> pd.DataFrame:
    return pd.read_csv(path, dtype=str, keep_default_na=False)


def _validate_metrics(df: pd.DataFrame, require_source_tags: bool) -> tuple[list[str], list[str]]:
    errors: list[str] = []
    warnings: list[str] = []

    missing_cols = require_columns(df, REQ_METRICS)
    if missing_cols:
        errors.append(f"metrics.csv missing columns: {missing_cols}")
        return errors, warnings

    # Basic row checks
    if len(df) == 0:
        errors.append("metrics.csv has no rows")
        return errors, warnings

    for i, row in df.iterrows():
        metric = row.get("MetricName")
        value = row.get("Value")
        src = row.get("SourceTag")
        gaap_flag = str(row.get("GAAP_Flag") or "").strip()

        if is_missing(metric):
            errors.append(f"metrics.csv row {i + 2}: MetricName is required")

        if not is_missing(value):
            # Only enforce SourceTag when the metric is present
            if require_source_tags and is_missing(src):
                errors.append(
                    f"metrics.csv row {i + 2} ({metric}): Value present but SourceTag is MISSING"
                )

        if gaap_flag == "Non-GAAP":
            comp = row.get("ComparableGAAPMetricName")
            recon = row.get("ReconciliationSourceTag")
            if is_missing(comp) or is_missing(recon):
                errors.append(
                    f"metrics.csv row {i + 2} ({metric}): Non-GAAP requires ComparableGAAPMetricName and ReconciliationSourceTag"
                )

    return errors, warnings


def _validate_estimates(df: pd.DataFrame) -> tuple[list[str], list[str]]:
    errors: list[str] = []
    warnings: list[str] = []

    missing_cols = require_columns(df, REQ_ESTIMATES)
    if missing_cols:
        errors.append(f"estimates.csv missing columns: {missing_cols}")
        return errors, warnings

    allowed = {"Consensus", "Internal", "Whisper"}
    for i, row in df.iterrows():
        et = str(row.get("EstimateType") or "").strip()
        if et and et not in allowed:
            errors.append(
                f"estimates.csv row {i + 2}: EstimateType must be one of {sorted(allowed)}"
            )
    return errors, warnings


def _validate_guidance(df: pd.DataFrame, require_source_tags: bool) -> tuple[list[str], list[str]]:
    errors: list[str] = []
    warnings: list[str] = []

    missing_cols = require_columns(df, REQ_GUIDANCE)
    if missing_cols:
        errors.append(f"guidance.csv missing columns: {missing_cols}")
        return errors, warnings

    for i, row in df.iterrows():
        metric = row.get("MetricName")
        low = as_float_or_none(row.get("Low"))
        high = as_float_or_none(row.get("High"))
        src = row.get("SourceTag")

        if require_source_tags and (low is not None or high is not None) and is_missing(src):
            errors.append(
                f"guidance.csv row {i + 2} ({metric}): guidance present but SourceTag is MISSING"
            )

        if low is not None and high is not None and low > high:
            errors.append(f"guidance.csv row {i + 2} ({metric}): Low > High")

        if (low is None) ^ (high is None):
            warnings.append(
                f"guidance.csv row {i + 2} ({metric}): only one of Low/High is populated"
            )

    return errors, warnings


def _validate_quotes(df: pd.DataFrame, require_source_tags: bool) -> tuple[list[str], list[str]]:
    errors: list[str] = []
    warnings: list[str] = []

    missing_cols = require_columns(df, REQ_QUOTES)
    if missing_cols:
        errors.append(f"quotes.csv missing columns: {missing_cols}")
        return errors, warnings

    allowed = {"Prepared", "Q&A"}
    for i, row in df.iterrows():
        section = str(row.get("Section") or "").strip()
        if section and section not in allowed:
            errors.append(f"quotes.csv row {i + 2}: Section must be Prepared or Q&A")

        qt = row.get("QuoteText")
        if not is_missing(qt) and require_source_tags and is_missing(row.get("SourceTag")):
            errors.append(f"quotes.csv row {i + 2}: QuoteText present but SourceTag is MISSING")

    return errors, warnings


def _validate_driver_updates(
    df: pd.DataFrame, require_source_tags: bool
) -> tuple[list[str], list[str]]:
    errors: list[str] = []
    warnings: list[str] = []

    missing_cols = require_columns(df, REQ_DRIVER_UPDATES)
    if missing_cols:
        errors.append(f"driver_updates.csv missing columns: {missing_cols}")
        return errors, warnings

    for i, row in df.iterrows():
        dv = row.get("NewValue")
        if not is_missing(dv) and as_float_or_none(dv) is None:
            warnings.append(
                f"driver_updates.csv row {i + 2}: NewValue is non-numeric (will be written as text)"
            )

        if require_source_tags and not is_missing(dv) and is_missing(row.get("SourceTag")):
            errors.append(
                f"driver_updates.csv row {i + 2}: NewValue present but SourceTag is MISSING"
            )

    return errors, warnings


def main() -> int:
    if len(sys.argv) != 2:
        print("Usage: python scripts/validate_normalized_inputs.py plan.json")
        return 1

    plan_path = sys.argv[1]
    plan = read_json(plan_path)

    out_dir = plan.get("outputs", {}).get("output_dir", "output")
    audit_dir = ensure_dir(str(Path(out_dir) / "audit"))

    require_source_tags = bool(plan.get("controls", {}).get("require_source_tags", True))

    norm = plan.get("inputs", {}).get("normalized", {})
    paths = {
        "metrics": norm.get("metrics_csv"),
        "estimates": norm.get("estimates_csv"),
        "guidance": norm.get("guidance_csv"),
        "quotes": norm.get("quotes_csv"),
        "driver_updates": norm.get("driver_updates_csv"),
    }

    errors: list[str] = []
    warnings: list[str] = []

    dfs: dict[str, pd.DataFrame] = {}
    for k, p in paths.items():
        if not p:
            errors.append(f"Plan missing normalized path for {k}")
            continue
        if not Path(p).exists():
            errors.append(f"Missing file: {p} (copy from assets/templates)")
            continue
        try:
            dfs[k] = _read_csv(p)
        except Exception as e:
            errors.append(f"Failed to read {p}: {e}")

    if "metrics" in dfs:
        e, w = _validate_metrics(dfs["metrics"], require_source_tags)
        errors += e
        warnings += w
    if "estimates" in dfs:
        e, w = _validate_estimates(dfs["estimates"])
        errors += e
        warnings += w
    if "guidance" in dfs:
        e, w = _validate_guidance(dfs["guidance"], require_source_tags)
        errors += e
        warnings += w
    if "quotes" in dfs:
        e, w = _validate_quotes(dfs["quotes"], require_source_tags)
        errors += e
        warnings += w
    if "driver_updates" in dfs:
        e, w = _validate_driver_updates(dfs["driver_updates"], require_source_tags)
        errors += e
        warnings += w

    # Render report
    lines: list[str] = []
    lines.append("# Normalized Inputs Validation Report\n")
    lines.append(f"Plan: `{plan_path}`\n")

    if warnings:
        lines.append("## Warnings")
        for w in warnings:
            lines.append(f"- {w}")
        lines.append("")

    if errors:
        lines.append("## Errors")
        for e in errors:
            lines.append(f"- {e}")
        lines.append("")

    if not errors:
        lines.append("✅ Inputs are valid (warnings may exist).")

    report_path = audit_dir / "ValidationReport_NormalizedInputs.md"
    write_text("\n".join(lines), str(report_path))

    # Console
    if warnings:
        print("WARNINGS:")
        for w in warnings:
            print(f"  - {w}")
    if errors:
        print("ERRORS:")
        for e in errors:
            print(f"  - {e}")
        print(f"\nSee {report_path}")
        return 1

    print(f"Inputs valid. See {report_path}")
    return 0


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

SHA-256: 37ed783b18e5bfd66c85afebd026e7aab0f54814bf7353914bf3150cf5b2f9fa