← Files Public Equity InvestingARCHIVED FILE

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

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

↓ Download file

#!/usr/bin/env python3
"""Apply driver updates to an Excel operating model via a driver registry.

This script is intentionally limited to **explicit** mapped inputs:
- NamedRange (single-cell) OR
- Worksheet + Cell

It does NOT attempt to recalc formulas (Excel should recalc on open).

Outputs:
- Updated model copy with version tag in filename
- output/audit/ChangeLog.csv (and optional ChangeLog worksheet)
"""

from __future__ import annotations

import shutil
import sys
from datetime import datetime
from pathlib import Path
from typing import Any

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/apply_model_updates.py plan.json")
    print("Apply explicit driver updates to an Excel model via a driver registry.")
    raise SystemExit(0)

import openpyxl
import pandas as pd

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


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


def _now_iso(tz: str | None = None) -> str:
    # Use local time; timezone label is informational.
    return datetime.now().isoformat(timespec="seconds")


def _make_version_tag(plan: dict[str, Any]) -> str:
    ev = plan.get("event", {})
    ticker = ev.get("ticker", "MISSING")
    period = ev.get("fiscal_period", "MISSING")
    tz = (
        plan.get("outputs", {})
        .get("model_update", {})
        .get("version_tag_timezone", ev.get("timezone", ""))
    )
    ts = datetime.now().strftime("%Y.%m.%d_%H%M")
    tz_lbl = tz.split("/")[-1] if tz else "LOCAL"
    return f"{ticker}_Model_v{ts}{tz_lbl}_{period}_PostEarnings"


def _safe_name(x: str) -> str:
    return "".join([c for c in x if c.isalnum() or c in "._-"])


def _append_changelog_sheet(wb: openpyxl.Workbook, changelog_df: pd.DataFrame) -> None:
    ws_name = "ChangeLog"
    if ws_name in wb.sheetnames:
        ws = wb[ws_name]
    else:
        ws = wb.create_sheet(ws_name)
        ws.append(list(changelog_df.columns))

    # Find first empty row
    row_idx = ws.max_row + 1
    for _, row in changelog_df.iterrows():
        ws.append([row.get(c) for c in changelog_df.columns])


def apply_model_updates(plan: dict[str, Any]) -> tuple[Path, Path]:
    out_dir = Path(plan.get("outputs", {}).get("output_dir", "output"))
    ensure_dir(str(out_dir / "model"))
    ensure_dir(str(out_dir / "audit"))

    model = plan.get("inputs", {}).get("model", {})
    prior_model = model.get("prior_model_xlsx")
    driver_reg_path = model.get("driver_registry_csv")

    norm = plan.get("inputs", {}).get("normalized", {})
    driver_updates_path = norm.get("driver_updates_csv")

    if is_missing(prior_model) or is_missing(driver_reg_path) or is_missing(driver_updates_path):
        raise ValueError(
            "Model update requires prior_model_xlsx, driver_registry_csv, and driver_updates_csv"
        )

    prior_model_path = Path(prior_model)
    if not prior_model_path.exists():
        raise FileNotFoundError(f"Prior model not found: {prior_model}")

    version_tag = _make_version_tag(plan)
    ext = prior_model_path.suffix
    new_name = _safe_name(version_tag) + ext
    new_model_path = out_dir / "model" / new_name

    dry_run = bool(plan.get("controls", {}).get("dry_run", False))
    if not dry_run:
        shutil.copy2(prior_model_path, new_model_path)

    driver_reg = _read_csv(driver_reg_path)
    updates = _read_csv(driver_updates_path)

    # Index registry by DriverID
    reg = {str(r["DriverID"]): r for _, r in driver_reg.iterrows()}

    changelog_rows = []
    changed_by = plan.get("controls", {}).get("prepared_by", "AI")
    model_version_id = version_tag

    keep_vba = ext.lower() in {".xlsm", ".xltm"}
    wb = openpyxl.load_workbook(
        new_model_path if not dry_run else prior_model_path, keep_vba=keep_vba
    )

    try:
        for _, u in updates.iterrows():
            did = str(u.get("DriverID") or "").strip()
            if not did:
                continue
            if did not in reg:
                # Unknown driver: skip but record
                changelog_rows.append(
                    {
                        "Timestamp": _now_iso(),
                        "ModelVersionID": model_version_id,
                        "ChangedBy": changed_by,
                        "Section": "Drivers",
                        "ItemChanged": did,
                        "OldValue": "MISSING",
                        "NewValue": u.get("NewValue"),
                        "Why": u.get("Why"),
                        "SourceTag": u.get("SourceTag"),
                    }
                )
                continue

            new_val_raw = u.get("NewValue")
            if is_missing(new_val_raw):
                continue

            r = reg[did]
            mapping_type = str(r.get("MappingType") or "").strip() or "NamedRange"
            sheet = str(r.get("Worksheet") or "").strip() or None
            cell = str(r.get("Cell") or "").strip() or None
            named_range = str(r.get("NamedRange") or "").strip() or None

            # Read old value (from mapped cell)
            try:
                if mapping_type == "NamedRange" and named_range:
                    # resolve and read via openpyxl
                    dn = wb.defined_names.get(named_range)
                    old_val = None
                    if dn:
                        dests = list(dn.destinations)
                        if len(dests) == 1:
                            sname, addr = dests[0]
                            old_val = wb[sname][addr].value
                elif mapping_type == "Cell" and sheet and cell:
                    old_val = wb[sheet][cell].value
                else:
                    old_val = None
            except Exception:
                old_val = None

            # Parse numeric if possible
            new_val_num = as_float_or_none(new_val_raw)
            new_val = new_val_num if new_val_num is not None else new_val_raw

            # Write
            try:
                ws_name, ws_cell = set_value(wb, mapping_type, sheet, cell, named_range, new_val)
            except Exception as e:
                changelog_rows.append(
                    {
                        "Timestamp": _now_iso(),
                        "ModelVersionID": model_version_id,
                        "ChangedBy": changed_by,
                        "Section": "Drivers",
                        "ItemChanged": did,
                        "OldValue": str(old_val) if old_val is not None else "MISSING",
                        "NewValue": str(new_val_raw),
                        "Why": u.get("Why"),
                        "SourceTag": u.get("SourceTag"),
                    }
                )
                continue

            changelog_rows.append(
                {
                    "Timestamp": _now_iso(),
                    "ModelVersionID": model_version_id,
                    "ChangedBy": changed_by,
                    "Section": "Drivers",
                    "ItemChanged": did,
                    "OldValue": str(old_val) if old_val is not None else "MISSING",
                    "NewValue": str(new_val_raw),
                    "Why": u.get("Why"),
                    "SourceTag": u.get("SourceTag"),
                }
            )

        changelog_df = pd.DataFrame(
            changelog_rows,
            columns=[
                "Timestamp",
                "ModelVersionID",
                "ChangedBy",
                "Section",
                "ItemChanged",
                "OldValue",
                "NewValue",
                "Why",
                "SourceTag",
            ],
        )

        changelog_path = out_dir / "audit" / "ChangeLog.csv"
        changelog_df.to_csv(changelog_path, index=False)

        # Optional: add/append ChangeLog tab
        try:
            if len(changelog_df) > 0:
                _append_changelog_sheet(wb, changelog_df)
        except Exception:
            pass

        if not dry_run:
            wb.save(new_model_path)

        return new_model_path, changelog_path
    finally:
        wb.close()


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

    plan = read_json(sys.argv[1])
    new_model, changelog = apply_model_updates(plan)
    print(f"Wrote updated model: {new_model}")
    print(f"Wrote changelog: {changelog}")
    return 0


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

SHA-256: b3b253d5f00cdb9abb3ce9f3274c30d1b57b3efa43057d238fb555f411cecbf0