diff --git a/AGENTS.md b/AGENTS.md index 9da1ffe..35bc88e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,14 @@ small, trustworthy accounting kernel. 9. Present only promotion-eligible challengers for human ratification. Never replace the champion without explicit approval. +## Excel Output + +When the user wants Excel output with working formulas, use `model_to_excel` for +the standard monthly model. For any other cadence or layout, generate a +company-specific exporter from `pyfpa.excel.toolkit` in the generated namespace +and register it as a report entrypoint. Install `formulas` and run +`verify_workbook` before delivering. No workbook ships unverified. + ## Kernel Guardrails - Never hide reconciliation differences, missing actuals, duplicate names, or diff --git a/CLAUDE.md b/CLAUDE.md index fc65927..29b5670 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,3 +57,7 @@ After the initial architecture is approved, use `fpa-research-loop` for bounded autonomous improvement. You may generate, evaluate, and discard challengers without asking for approval each time. Human approval is required for promotion before a challenger replaces the champion. + +For Excel output with working formulas, use `openfpa model-export` or +`pyfpa.model_to_excel` for the standard monthly model. Run `verify_workbook` +before delivering. No workbook ships unverified. diff --git a/README.md b/README.md index a165d2d..6f73db6 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,7 @@ openfpa scorecard-render openfpa experiment-list openfpa context-pack openfpa onboarding-render +openfpa model-export ``` In a source checkout without the `openfpa` console script installed, every @@ -394,7 +395,9 @@ The base kernel includes: - 13-week direct-method cash forecasting; - revenue, COGS, operating-expense, debt, tax, and working-capital primitives; - reconciliation, segment, SKU, and divestiture analysis helpers; -- CSV ingestion and Markdown or Excel reporting; +- CSV ingestion and Markdown or Excel reporting: static value export via + `forecast_to_excel`, and a live-formula model workbook via `model_to_excel` + (named assumption cells, real formulas, verified against the engine in CI); - forecast snapshots, scoring, and holdout backtests; - workspace, intake, correction, experiment, retrieval, and research records; - experimental cross-company prior and skill mining. @@ -403,6 +406,14 @@ The base kernel includes: with cohorts, projects, contracts, fleets, stores, or complex inventory may need a different generated model. +The `model_to_excel` function compiles an `EntityConfig` into a two-sheet workbook: +an Assumptions sheet of named, editable driver cells and a Model sheet where every +P&L and cash-flow line is a real formula referencing those names. The `verify_workbook` +function evaluates the workbook with a Python formula engine and compares every line +and month to `cashflow_from_config`, so the workbook is verified against the engine +before it is used. For cadences or layouts the kernel does not cover, compose +`pyfpa.excel.toolkit` in a company-specific exporter and verify it the same way. + ## Skills The repository includes skills for: diff --git a/docs/superpowers/plans/2026-06-10-excel-live-model.md b/docs/superpowers/plans/2026-06-10-excel-live-model.md new file mode 100644 index 0000000..38f519d --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-excel-live-model.md @@ -0,0 +1,827 @@ +# Live-Formula Excel Export Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A native toolbelt capability for live-formula Excel: `pyfpa/excel/` (formula toolkit, canonical monthly translator, verification harness), an `fpa-excel-model` skill, a `model-export` CLI command, and CI proof that the workbook reproduces the engine to the dollar. + +**Architecture:** `model_to_excel(cfg, path)` is a second compile target for `EntityConfig`: an Assumptions sheet of named driver cells plus a Model sheet whose cells are real Excel formulas referencing those names. The translator bakes structural facts (calendar-month seasonality references, growth-year exponents, instrument layout) at generation time so the formula vocabulary stays within arithmetic, `^`, `SUM`, `MIN`, `MAX`, `IF`. `verify_workbook` evaluates the workbook with the `formulas` library and compares every line and month to `cashflow_from_config`. Cadence variants are agent work composed from the toolkit, never kernel variants. + +**Tech Stack:** Python 3.11+, openpyxl (existing runtime dep), `formulas` (new DEV-ONLY extra for verification), pydantic v2, pandas. + +**Spec:** `docs/superpowers/specs/2026-06-10-excel-live-model-design.md` + +House rules: tests FLAT in `tests/`; no em dashes anywhere; pure/immutable; small files; `python3 -m pytest`. + +--- + +## File Structure + +| Path | Responsibility | +| --- | --- | +| `pyfpa/excel/__init__.py` (create) | Re-export `model_to_excel`, `verify_workbook`, toolkit names. | +| `pyfpa/excel/toolkit.py` (create) | Named cells/rows, formula filling, formats. Pure openpyxl, no engine imports. | +| `pyfpa/excel/model_workbook.py` (create) | The canonical monthly translator. | +| `pyfpa/excel/verify.py` (create) | `VerifyReport`, `verify_workbook` (lazy `formulas` import). | +| `pyfpa/__init__.py` (modify) | Export `model_to_excel`, `verify_workbook`. | +| `pyproject.toml` (modify) | Add `formulas` to the dev extra. | +| `pyfpa/cli_commands/learning.py` or new `cli_commands/reporting.py` (create) | `model-export` handler (thin). | +| `pyfpa/cli.py` (modify) | Register `model-export`. | +| `skills/fpa-excel-model/SKILL.md` (create) | The workflow skill. | +| `skills/fpa-board-briefing/SKILL.md`, `skills/fpa-scaffold-model/SKILL.md` (modify) | One-line pointers. | +| `AGENTS.md`, `CLAUDE.md`, `README.md` (modify) | Contract section + README mention. | +| `examples/ridgeline/run_demo.py` (modify) | Also emit `model.xlsx`. | +| `tests/test_excel_toolkit.py`, `tests/test_excel_model_structure.py`, `tests/test_excel_verify.py`, `tests/test_excel_equivalence.py`, `tests/test_cli_model_export.py` (create) | Tests. | +| `tests/test_public_api.py` (modify) | Contract-test sync. | + +--- + +## Group 1: the toolkit + +### Task 1: `pyfpa/excel/toolkit.py` + +**Files:** +- Create: `pyfpa/excel/__init__.py` (empty for now), `pyfpa/excel/toolkit.py` +- Test: `tests/test_excel_toolkit.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_excel_toolkit.py +from openpyxl import Workbook, load_workbook + +from pyfpa.excel.toolkit import ( + add_named_cell, add_named_row, fill_formula_row, money_format, percent_format, +) + + +def test_add_named_cell_registers_defined_name(tmp_path): + wb = Workbook() + ws = wb.active + ws.title = "Assumptions" + add_named_cell(wb, ws, name="tax_rate", row=2, col=2, value=0.21, number_format=percent_format()) + path = tmp_path / "t.xlsx" + wb.save(path) + back = load_workbook(path) + assert "tax_rate" in back.defined_names + assert back["Assumptions"].cell(row=2, column=2).value == 0.21 + + +def test_add_named_row_registers_range(tmp_path): + wb = Workbook() + ws = wb.active + ws.title = "Assumptions" + add_named_row(wb, ws, name="seasonality_ch1", row=3, start_col=2, + values=[1.0] * 12, number_format=None) + path = tmp_path / "t.xlsx" + wb.save(path) + back = load_workbook(path) + assert "seasonality_ch1" in back.defined_names + assert back["Assumptions"].cell(row=3, column=13).value == 1.0 + + +def test_fill_formula_row_writes_formula_strings(): + wb = Workbook() + ws = wb.active + ws.title = "Model" + # template receives 1-based month index and the column letter for that month + fill_formula_row( + ws, row=5, label="gross_profit", start_col=2, n_cols=3, + template=lambda m, col: f"={col}3-{col}4", + ) + assert ws.cell(row=5, column=1).value == "gross_profit" + assert ws.cell(row=5, column=2).value == "=B3-B4" + assert ws.cell(row=5, column=4).value == "=D3-D4" + + +def test_money_and_percent_formats_are_strings(): + assert isinstance(money_format(), str) + assert isinstance(percent_format(), str) +``` + +- [ ] **Step 2: Run, confirm fail** (`ModuleNotFoundError: pyfpa.excel`). + +Run: `python3 -m pytest tests/test_excel_toolkit.py -v` + +- [ ] **Step 3: Implement** `pyfpa/excel/toolkit.py`: + +```python +from __future__ import annotations + +from collections.abc import Callable, Sequence + +from openpyxl.utils import get_column_letter +from openpyxl.workbook import Workbook +from openpyxl.workbook.defined_name import DefinedName +from openpyxl.worksheet.worksheet import Worksheet + + +def money_format() -> str: + return "#,##0" + + +def percent_format() -> str: + return "0.0%" + + +def days_format() -> str: + return "0.0" + + +def add_named_cell( + wb: Workbook, + ws: Worksheet, + *, + name: str, + row: int, + col: int, + value: float, + number_format: str | None = None, +) -> None: + """Write a value and register a workbook-scoped defined name for the cell.""" + cell = ws.cell(row=row, column=col, value=value) + if number_format: + cell.number_format = number_format + ref = f"'{ws.title}'!${get_column_letter(col)}${row}" + wb.defined_names[name] = DefinedName(name=name, attr_text=ref) + + +def add_named_row( + wb: Workbook, + ws: Worksheet, + *, + name: str, + row: int, + start_col: int, + values: Sequence[float], + number_format: str | None = None, +) -> None: + """Write a horizontal run of values and register a defined name for the range.""" + for offset, value in enumerate(values): + cell = ws.cell(row=row, column=start_col + offset, value=value) + if number_format: + cell.number_format = number_format + first = f"${get_column_letter(start_col)}${row}" + last = f"${get_column_letter(start_col + len(values) - 1)}${row}" + wb.defined_names[name] = DefinedName( + name=name, attr_text=f"'{ws.title}'!{first}:{last}" + ) + + +def fill_formula_row( + ws: Worksheet, + *, + row: int, + label: str, + start_col: int, + n_cols: int, + template: Callable[[int, str], str], + number_format: str | None = None, +) -> None: + """Label column A, then fill each month cell with template(month_index, column_letter). + + template receives the 1-based month index and that month's column letter and + must return a full formula string starting with '='.""" + ws.cell(row=row, column=1, value=label) + for m in range(1, n_cols + 1): + col = get_column_letter(start_col + m - 1) + cell = ws.cell(row=row, column=start_col + m - 1, value=template(m, col)) + if number_format: + cell.number_format = number_format + + +def freeze_header(ws: Worksheet, *, first_data_cell: str = "B2") -> None: + ws.freeze_panes = first_data_cell +``` + +- [ ] **Step 4: Run, confirm pass.** Then full suite, no regressions. + +- [ ] **Step 5: Commit** + +```bash +git add pyfpa/excel/ tests/test_excel_toolkit.py +git commit -m "feat: excel formula toolkit (named cells/rows, formula fill, formats)" +``` + +--- + +## Group 2: the canonical translator + +### Task 2: Assumptions sheet + Model sheet structure + +**Files:** +- Create: `pyfpa/excel/model_workbook.py` +- Test: `tests/test_excel_model_structure.py` + +The translator compiles `EntityConfig` to a two-sheet workbook. Layout contract +(the structure test pins this): + +**Assumptions sheet** (defined names; one block per concern): +- Per channel i (1-based): `rev_annual_ch{i}`, `growth_ch{i}`, `cogs_pct_ch{i}` + (cells) and `seasonality_ch{i}` (12-cell named row). +- Per opex line j: fixed lines get `opex_amount_{j}`; variable lines get + `opex_pct_{j}`. +- Cells: `dso_days`, `dio_days`, `dpo_days`, `tax_rate`, `da_monthly`, + `capex_monthly`, `open_cash`, `open_ar`, `open_ap`, `open_inventory`, + `open_nol`. +- Per debt instrument k: `debt_open_{k}`, `debt_rate_{k}`, `debt_prin_{k}`. + +**Model sheet** (months across columns B..; labels in column A; every data cell +a formula). Row map, in order, with the exact formula pattern each row uses +(month m, column letter C; `[C-1]` means the previous month's column; literals +baked at generation time are marked BAKED): + +| Row label | Formula pattern (per month cell) | +| --- | --- | +| `revenue_ch{i}` | `=rev_annual_ch{i}*(W/SUM(seasonality_ch{i}))*(1+growth_ch{i})^Y` where `W` is the direct cell reference into `seasonality_ch{i}` for this month's CALENDAR month (BAKED: the translator computes the calendar month from cfg.start_month, matching `revenue_from_config`'s `period.month` indexing) and `Y = (m-1)//12` (BAKED literal). | +| `revenue_total` | `=SUM(C{first_ch_row}:C{last_ch_row})` over the channel rows. | +| `cogs_total` | `=` sum of `revenue_ch{i}*cogs_pct_ch{i}` terms (explicit `+` chain over channels, BAKED row refs). | +| `gross_profit` | `=C{revenue_total}-C{cogs_total}` | +| `opex_{j}` (fixed) | `=opex_amount_{j}` | +| `opex_{j}` (variable) | `=C{revenue_total}*opex_pct_{j}` | +| `opex_total` | `=SUM(...)` over opex rows (or `=0` when no opex lines). | +| `ebitda` | `=C{gross_profit}-C{opex_total}` | +| `da` | `=da_monthly` | +| `debt_balance_{k}` (term loan) | month 1: `=debt_open_{k}-MIN(debt_prin_{k},debt_open_{k})`; else `=[C-1]{row}-MIN(debt_prin_{k},[C-1]{row})` (balance AFTER payment; interest reads the PRE-payment balance, below). | +| `debt_balance_{k}` (loc) | month 1: `=debt_open_{k}`; else `=[C-1]{row}` | +| `interest_{k}` | month 1: `=debt_open_{k}*debt_rate_{k}/12`; else `=[C-1]{bal_row}*debt_rate_{k}/12` (matches engine: interest on balance before this month's principal). | +| `principal_{k}` (term loan) | month 1: `=MIN(debt_prin_{k},debt_open_{k})`; else `=MIN(debt_prin_{k},[C-1]{bal_row})`; (loc) `=0` | +| `interest_total` / `principal_total` | `=SUM(...)` over instrument rows (or `=0`). | +| `pretax_income` | `=C{ebitda}-C{da}-C{interest_total}` (engine: EBIT minus interest; D&A is expensed). NOTE: read `pyfpa/models/cashflow.py` first and mirror EXACTLY (`pretax = ebit - interest` where `ebit = ebitda - da`). | +| `nol_opening` | month 1: `=open_nol`; else `=[C-1]{nol_closing}` | +| `nol_used` | `=MIN(C{nol_opening},MAX(0,C{pretax_income}))` | +| `nol_closing` | `=C{nol_opening}-C{nol_used}` | +| `tax` | `=(MAX(0,C{pretax_income})-C{nol_used})*tax_rate` | +| `net_income` | `=C{pretax_income}-C{tax}` | +| `ar_balance` | `=C{revenue_total}*dso_days/30` | +| `ap_balance` | `=C{cogs_total}*dpo_days/30` | +| `inv_balance` | `=C{cogs_total}*dio_days/30` | +| `wc_cash_impact` | month 1: `=-(C{ar}-open_ar)+(C{ap}-open_ap)-(C{inv}-open_inventory)`; else `=-(C{ar}-[C-1]{ar})+(C{ap}-[C-1]{ap})-(C{inv}-[C-1]{inv})` | +| `operating_cash_flow` | `=C{net_income}+C{da}+C{wc_cash_impact}` | +| `capex` | `=capex_monthly` | +| `free_cash_flow` | `=C{operating_cash_flow}-C{capex}` | +| `change_in_cash` | `=C{free_cash_flow}-C{principal_total}` | +| `ending_cash` | month 1: `=open_cash+C{change_in_cash}`; else `=[C-1]{ending_cash}+C{change_in_cash}` | + +Implementation notes for the engineer: +- Build the row map as data first (a list of row specs), then emit; keep + `model_workbook.py` under 400 lines by composing `toolkit.fill_formula_row`. +- BEFORE writing formulas, read `pyfpa/models/{revenue,cogs,opex,working_capital,debt,cashflow}.py` + and mirror semantics exactly. The equivalence test in Group 3 is the referee. +- Header row 1 of the Model sheet: month labels from `month_index(cfg.start_month, cfg.horizon_months)`. +- Apply `money_format()` to currency rows, `percent_format()` where sensible, + `freeze_header`. + +- [ ] **Step 1: Write the failing structure test** + +```python +# tests/test_excel_model_structure.py +from openpyxl import load_workbook + +from pyfpa.config.schemas import EntityConfig +from pyfpa.excel.model_workbook import model_to_excel + + +def _cfg(): + return EntityConfig.model_validate({ + "name": "T", "start_month": "2026-01", "horizon_months": 12, "tax_rate": 0.21, + "channels": [ + {"name": "A", "annual_revenue": 1_200_000.0, "growth_rate": 0.10, + "seasonality": [1.0] * 12, "cogs_pct": 0.5}, + {"name": "B", "annual_revenue": 600_000.0, "growth_rate": 0.0, + "seasonality": [1, 1, 1, 1, 1, 1, 2, 2, 2, 1, 1, 1], "cogs_pct": 0.4}, + ], + "opex": [ + {"name": "salaries", "kind": "fixed", "monthly_amount": 30_000.0}, + {"name": "marketing", "kind": "variable", "pct_of_revenue": 0.05}, + ], + "debt": [ + {"name": "term", "kind": "term_loan", "opening_balance": 500_000.0, + "annual_rate": 0.08, "monthly_principal": 10_000.0}, + {"name": "loc", "kind": "loc", "opening_balance": 100_000.0, "annual_rate": 0.10}, + ], + "working_capital": {"dso_days": 45.0, "dpo_days": 30.0, "dio_days": 60.0}, + "opening_balances": {"cash": 50_000.0, "ar": 100_000.0, "ap": 60_000.0, + "inventory": 120_000.0, "nol": 40_000.0}, + "da_monthly": 2_000.0, "capex_monthly": 5_000.0, + }) + + +def test_workbook_has_named_assumptions_and_formula_cells(tmp_path): + path = tmp_path / "model.xlsx" + model_to_excel(_cfg(), path) + wb = load_workbook(path) + assert set(wb.sheetnames) == {"Assumptions", "Model"} + for name in ["rev_annual_ch1", "growth_ch1", "cogs_pct_ch1", "seasonality_ch1", + "rev_annual_ch2", "dso_days", "dio_days", "dpo_days", "tax_rate", + "da_monthly", "capex_monthly", "open_cash", "open_nol", + "debt_open_1", "debt_rate_1", "debt_prin_1", "debt_open_2", + "opex_amount_1", "opex_pct_2"]: + assert name in wb.defined_names, f"missing defined name: {name}" + model = wb["Model"] + labels = [model.cell(row=r, column=1).value for r in range(2, model.max_row + 1)] + for required in ["revenue_total", "gross_profit", "ebitda", "pretax_income", + "tax", "net_income", "wc_cash_impact", "operating_cash_flow", + "free_cash_flow", "change_in_cash", "ending_cash"]: + assert required in labels, f"missing model row: {required}" + # every data cell in a known row is a formula string, not a value + row_idx = labels.index("ending_cash") + 2 + for col in range(2, 2 + 12): + v = model.cell(row=row_idx, column=col).value + assert isinstance(v, str) and v.startswith("="), f"not a formula: {v!r}" + + +def test_formula_vocabulary_is_restricted(tmp_path): + import re + path = tmp_path / "model.xlsx" + model_to_excel(_cfg(), path) + wb = load_workbook(path) + model = wb["Model"] + allowed = {"SUM", "MIN", "MAX", "IF"} + for row in model.iter_rows(min_row=2, min_col=2): + for cell in row: + if isinstance(cell.value, str) and cell.value.startswith("="): + for fn in re.findall(r"([A-Z]{2,})\s*\(", cell.value): + assert fn in allowed, f"forbidden function {fn} in {cell.value}" +``` + +- [ ] **Step 2: Run, confirm fail** (`ModuleNotFoundError`). + +- [ ] **Step 3: Implement `model_to_excel`** per the layout contract and notes +above. Keep it composed from the toolkit; row-spec list first, then two +emit passes (assumptions, model). + +- [ ] **Step 4: Run the structure tests, then the full suite.** + +- [ ] **Step 5: Commit** + +```bash +git add pyfpa/excel/model_workbook.py tests/test_excel_model_structure.py +git commit -m "feat: canonical monthly model-to-excel translator (live formulas)" +``` + +--- + +## Group 3: verification harness + equivalence proof + +### Task 3: `verify.py` + dev extra + +**Files:** +- Create: `pyfpa/excel/verify.py` +- Modify: `pyproject.toml` (dev extra) +- Test: `tests/test_excel_verify.py` + +- [ ] **Step 1: Add `formulas` to the dev extra in `pyproject.toml`** (read the +file; extend the existing `[project.optional-dependencies] dev` list with +`"formulas>=1.2"`). Then `pip install formulas` locally. + +- [ ] **Step 2: Write the failing test** + +```python +# tests/test_excel_verify.py +import pandas as pd +import pytest + +pytest.importorskip("formulas") + +from pyfpa.config.schemas import EntityConfig +from pyfpa.excel.model_workbook import model_to_excel +from pyfpa.excel.verify import verify_workbook +from pyfpa.models.cashflow import cashflow_from_config + + +def _simple_cfg(): + return EntityConfig.model_validate({ + "name": "T", "start_month": "2026-01", "horizon_months": 6, "tax_rate": 0.0, + "channels": [{"name": "A", "annual_revenue": 120_000.0, "growth_rate": 0.0, + "seasonality": [1.0] * 12, "cogs_pct": 0.5}], + "opex": [], "debt": [], + "working_capital": {"dso_days": 0.0, "dpo_days": 0.0, "dio_days": 0.0}, + "opening_balances": {"cash": 0.0}, + }) + + +def test_verify_passes_on_faithful_workbook(tmp_path): + cfg = _simple_cfg() + path = tmp_path / "m.xlsx" + model_to_excel(cfg, path) + report = verify_workbook(path, cashflow_from_config(cfg)) + assert report.passed, report.failures + + +def test_verify_fails_on_corrupted_formula(tmp_path): + from openpyxl import load_workbook + cfg = _simple_cfg() + path = tmp_path / "m.xlsx" + model_to_excel(cfg, path) + wb = load_workbook(path) + model = wb["Model"] + labels = {model.cell(row=r, column=1).value: r for r in range(2, model.max_row + 1)} + model.cell(row=labels["gross_profit"], column=3, value="=1234567") + wb.save(path) + report = verify_workbook(path, cashflow_from_config(cfg)) + assert not report.passed + assert any("gross_profit" in f for f in report.failures) + + +def test_missing_formulas_dependency_message(): + # exercised via the lazy-import shim: patch the import to raise and assert + # the error names the install command + import pyfpa.excel.verify as v + import builtins + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "formulas": + raise ImportError("nope") + return real_import(name, *args, **kwargs) + + builtins.__import__ = fake_import + try: + with pytest.raises(RuntimeError, match="pip install formulas"): + v._load_formulas() + finally: + builtins.__import__ = real_import +``` + +- [ ] **Step 3: Implement** `pyfpa/excel/verify.py`: + +```python +from __future__ import annotations + +from pathlib import Path + +import pandas as pd +from pydantic import BaseModel + +from openpyxl import load_workbook + + +class VerifyReport(BaseModel): + passed: bool + failures: list[str] + max_rel_deviation: float + lines_checked: int + + +def _load_formulas(): + try: + import formulas # noqa: PLC0415 (lazy on purpose: dev-only dependency) + except ImportError as exc: + raise RuntimeError( + "workbook verification requires the 'formulas' package: pip install formulas" + ) from exc + return formulas + + +def verify_workbook( + path: str | Path, + expected: pd.DataFrame, + *, + rel_tol: float = 1e-6, +) -> VerifyReport: + """Evaluate the workbook's formulas in Python and compare every Model-sheet + line that matches a column of `expected`, month by month. NaN or + unevaluated cells are failures, never skipped.""" + formulas = _load_formulas() + path = Path(path) + solution = formulas.ExcelModel().loads(str(path)).finish().calculate() + + wb = load_workbook(path) + model = wb["Model"] + labels = {model.cell(row=r, column=1).value: r for r in range(2, model.max_row + 1)} + + book_key = path.name.upper() + failures: list[str] = [] + max_dev = 0.0 + lines = 0 + for line in expected.columns: + if line not in labels: + continue + lines += 1 + row = labels[line] + for m in range(len(expected.index)): + col = 2 + m + from openpyxl.utils import get_column_letter + ref = f"'[{book_key}]MODEL'!{get_column_letter(col)}{row}" + key = next((k for k in solution if k.upper() == ref.upper()), None) + if key is None: + failures.append(f"{line} month {m + 1}: cell not evaluated") + continue + got = float(solution[key].value[0, 0]) + want = float(expected[line].iloc[m]) + denom = max(abs(want), 1.0) + dev = abs(got - want) / denom + max_dev = max(max_dev, dev) + if not dev <= rel_tol: + failures.append(f"{line} month {m + 1}: workbook {got!r} vs engine {want!r}") + return VerifyReport( + passed=not failures and lines > 0, + failures=failures, + max_rel_deviation=max_dev, + lines_checked=lines, + ) +``` + +NOTE for the engineer: the `formulas` solution-key format above is the +documented shape but MUST be confirmed against the installed version (print a +few keys in a scratch run). Adjust the lookup, never the assertion bar. If the +library proves unable to evaluate a needed construct, simplify the emitted +formula in the translator rather than weakening verification. + +- [ ] **Step 4: Run, confirm pass. Full suite.** + +- [ ] **Step 5: Commit** + +```bash +git add pyfpa/excel/verify.py pyproject.toml tests/test_excel_verify.py +git commit -m "feat: workbook verification harness (formulas evaluator, dev-only dep)" +``` + +### Task 4: the equivalence proof (CI) + +**Files:** +- Test: `tests/test_excel_equivalence.py` + +- [ ] **Step 1: Write the test** (this is the spec's success criterion 2 and 3): + +```python +# tests/test_excel_equivalence.py +import pytest + +pytest.importorskip("formulas") + +from pyfpa.config.loader import load_config +from pyfpa.config.schemas import EntityConfig +from pyfpa.excel.model_workbook import model_to_excel +from pyfpa.excel.verify import verify_workbook +from pyfpa.models.cashflow import cashflow_from_config + + +def test_ridgeline_workbook_reproduces_engine(tmp_path): + cfg = load_config("examples/ridgeline/config.yaml") + path = tmp_path / "ridgeline.xlsx" + model_to_excel(cfg, path) + report = verify_workbook(path, cashflow_from_config(cfg)) + assert report.passed, report.failures[:10] + assert report.lines_checked >= 10 + + +def test_edge_config_nol_debt_seasonality_reproduces_engine(tmp_path): + cfg = EntityConfig.model_validate({ + "name": "Edge", "start_month": "2026-04", "horizon_months": 24, "tax_rate": 0.25, + "channels": [ + {"name": "A", "annual_revenue": 2_400_000.0, "growth_rate": 0.12, + "seasonality": [1, 1, 2, 3, 2, 1, 1, 1, 2, 3, 4, 3], "cogs_pct": 0.55}, + {"name": "B", "annual_revenue": 900_000.0, "growth_rate": -0.05, + "seasonality": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2], "cogs_pct": 0.35}, + ], + "opex": [ + {"name": "fixed_g_a", "kind": "fixed", "monthly_amount": 80_000.0}, + {"name": "var_mktg", "kind": "variable", "pct_of_revenue": 0.07}, + ], + "debt": [ + {"name": "term", "kind": "term_loan", "opening_balance": 240_000.0, + "annual_rate": 0.09, "monthly_principal": 25_000.0}, + {"name": "loc", "kind": "loc", "opening_balance": 150_000.0, "annual_rate": 0.11}, + ], + "working_capital": {"dso_days": 38.0, "dpo_days": 42.0, "dio_days": 75.0}, + "opening_balances": {"cash": 25_000.0, "ar": 180_000.0, "ap": 110_000.0, + "inventory": 260_000.0, "nol": 500_000.0}, + "da_monthly": 6_000.0, "capex_monthly": 9_000.0, + }) + path = tmp_path / "edge.xlsx" + model_to_excel(cfg, path) + report = verify_workbook(path, cashflow_from_config(cfg)) + assert report.passed, report.failures[:10] +``` + +The edge config deliberately exercises: term loan that fully amortizes inside +the horizon (240k at 25k/month exhausts in month 10, hitting the `MIN` branch), +NOL consumed across months, off-January start month (calendar seasonality +indexing), negative growth, and a 24-month horizon (year-offset exponent > 0). + +- [ ] **Step 2: Run.** Expected: both PASS. Any mismatch is a translator bug; +fix the translator (or its baked references), never the tolerance. + +- [ ] **Step 3: Full suite, commit** + +```bash +git add tests/test_excel_equivalence.py +git commit -m "test: CI proof that live-formula workbooks reproduce the engine" +``` + +--- + +## Group 4: exports + CLI + +### Task 5: public API + `model-export` + +**Files:** +- Modify: `pyfpa/excel/__init__.py`, `pyfpa/__init__.py`, `tests/test_public_api.py` +- Create: `pyfpa/cli_commands/reporting.py` +- Modify: `pyfpa/cli.py` +- Test: `tests/test_cli_model_export.py` + +- [ ] **Step 1: Fill `pyfpa/excel/__init__.py`** + +```python +from pyfpa.excel.toolkit import ( + add_named_cell, add_named_row, fill_formula_row, freeze_header, + money_format, percent_format, days_format, +) +from pyfpa.excel.model_workbook import model_to_excel +from pyfpa.excel.verify import VerifyReport, verify_workbook + +__all__ = [ + "add_named_cell", "add_named_row", "fill_formula_row", "freeze_header", + "money_format", "percent_format", "days_format", + "model_to_excel", "VerifyReport", "verify_workbook", +] +``` + +Export `model_to_excel` and `verify_workbook` from `pyfpa/__init__.py` and add +both names to the exact-set assertion in `tests/test_public_api.py` (read it; +keep exact-set equality). + +- [ ] **Step 2: CLI command.** Create `pyfpa/cli_commands/reporting.py` with a +`command_model_export(args)` handler in the established `cli_commands/learning.py` +style (workspace validation, JSON `_success` output): flags +` --config --out `; +loads via `pyfpa.config.loader.load_config`, calls `model_to_excel`, reports +the written path and sheet names. Register `model-export` in `pyfpa/cli.py` +following how the other `cli_commands` handlers are registered. Read both +files first and match exactly. + +- [ ] **Step 3: CLI test** `tests/test_cli_model_export.py` in the established +CLI-test style (read `tests/test_cli*.py` first): init a tmp workspace, copy or +point at `examples/ridgeline/config.yaml`, run `model-export`, assert JSON +`ok=true` and the xlsx exists with both sheets. + +- [ ] **Step 4: Full suite; `python3 -c "import pyfpa; print(pyfpa.model_to_excel, pyfpa.verify_workbook)"`.** + +- [ ] **Step 5: Commit** + +```bash +git add pyfpa/excel/__init__.py pyfpa/__init__.py pyfpa/cli_commands/reporting.py pyfpa/cli.py tests/test_public_api.py tests/test_cli_model_export.py +git commit -m "feat: export excel API + model-export CLI command" +``` + +--- + +## Group 5: skill + contract + demo + +### Task 6: `fpa-excel-model` skill + pointers + +**Files:** +- Create: `skills/fpa-excel-model/SKILL.md` +- Modify: `skills/fpa-board-briefing/SKILL.md`, `skills/fpa-scaffold-model/SKILL.md`, `AGENTS.md`, `CLAUDE.md` + +- [ ] **Step 1: Write `skills/fpa-excel-model/SKILL.md`** with this content +(frontmatter description must avoid colons inside the YAML value; no em dashes): + +```markdown +--- +name: fpa-excel-model +description: Use when the user wants their model in Excel with real working formulas - "export this to Excel", "a workbook I can hand my board", "something that recalculates when I change an assumption". Produces a live-formula workbook generated from the model structure and verified against the engine before delivery. +--- + +# Live-Formula Excel Model (Operate) + +## Overview + +Finance professionals ultimately want a workbook where changing an assumption +recalculates the model. This skill produces one from the model structure, with +named assumption cells and real formulas, then proves it reproduces the engine +before it ships. Static value dumps stay with `forecast_to_excel`; this skill +is for living models. + +**Core principle:** no workbook ships unverified. The workbook is generated +from the model, never hand-edited values. + +## The standard model + +For the kernel's monthly model, one call: + +```python +import pyfpa +cfg = pyfpa.load_config("config.yaml") +pyfpa.model_to_excel(cfg, "model.xlsx") +``` + +Or via CLI: `python3 -m pyfpa.cli model-export --config config.yaml --out model.xlsx`. + +The workbook has an Assumptions sheet (every driver a named, editable cell: +growth, COGS pct, seasonality, DSO/DIO/DPO, debt, tax, D&A, capex) and a Model +sheet where every line of the monthly P&L and cash flow is a formula +referencing those names. + +## Any other cadence or layout + +Quarterly, weekly, a tab per segment, a lender view: these are YOUR work, not +kernel variants. Compose `pyfpa.excel.toolkit` (`add_named_cell`, +`add_named_row`, `fill_formula_row`, formats) in a company-specific exporter +under the generated namespace (`models/generated//`), then register it: +`python3 -m pyfpa.cli entrypoint-register --name excel-quarterly --kind report ...`. +Keep formulas within the supported vocabulary (arithmetic, `^`, `SUM`, `MIN`, +`MAX`, `IF`) so verification works. + +## Verify before delivering (always) + +1. `pip install formulas` (verification-only dependency, not shipped at runtime). +2. ```python + report = pyfpa.verify_workbook("model.xlsx", pyfpa.cashflow_from_config(cfg)) + assert report.passed, report.failures + ``` +3. For a bespoke exporter, verify against the frame your exporter is meant to + reproduce (the engine frame, or its quarterly/weekly aggregation). +4. A failing report means the exporter is wrong. Fix the formulas, never the + tolerance, and never deliver an unverified workbook. + +## Guardrails + +- No workbook ships unverified. +- Generated from model structure; never paste computed values into formula cells. +- Supported formula vocabulary only; if you need a function outside it, restructure + the formula (helper rows are fine) rather than expanding the vocabulary. +- Company-specific exporters live in generated namespaces and get tests like any + other generated code. + +## Next + +Workbook delivered, then **fpa-board-briefing** (the narrative that goes with it). +``` + +- [ ] **Step 2: Pointers.** In `fpa-board-briefing/SKILL.md` add one line in its +workflow: "If the audience wants the model itself, produce the live-formula +workbook via **fpa-excel-model** alongside the briefing." In +`fpa-scaffold-model/SKILL.md` add: "A live-formula Excel edition of the model is +available via **fpa-excel-model**." + +- [ ] **Step 3: Contract.** In `AGENTS.md` (and a matching short line in +`CLAUDE.md`), add a paragraph in the style of the existing connector/entrypoint +rules: "When the user wants Excel output with working formulas, use +`model_to_excel` for the standard monthly model. For any other cadence or +layout, generate a company-specific exporter from `pyfpa.excel.toolkit` in the +generated namespace and register it as a report entrypoint. Install `formulas` +and run `verify_workbook` before delivering. No workbook ships unverified." + +- [ ] **Step 4: Frontmatter validation** + +Run: `python3 -c "import pathlib,yaml; [yaml.safe_load(p.read_text().split('---')[1]) for p in pathlib.Path('skills').rglob('SKILL.md')]; print('ok')"` + +- [ ] **Step 5: Commit** + +```bash +git add skills/ AGENTS.md CLAUDE.md +git commit -m "feat: fpa-excel-model skill + contract rules (no workbook ships unverified)" +``` + +### Task 7: Ridgeline demo + README + final + +**Files:** +- Modify: `examples/ridgeline/run_demo.py`, `README.md` + +- [ ] **Step 1: Demo.** Read `examples/ridgeline/run_demo.py`; after the existing +outputs, add `model_to_excel(cfg, /"model.xlsx")` and a printed line +naming the file. Run the demo; confirm the file exists and opens (load_workbook). + +- [ ] **Step 2: README.** In the kernel/IO bullet list, replace the +`forecast_to_excel` mention with both: static export (`forecast_to_excel`) and +live-formula model workbook (`model_to_excel`, verified against the engine in +CI). Add `model-export` to the CLI command list. One short paragraph in the +appropriate section; no em dashes; humble tone. + +- [ ] **Step 3: Full suite + dash sweep** + +Run: `python3 -m pytest -q` and +`python3 - <<'EOF'\nimport pathlib\nbad=[p for p in pathlib.Path('.').rglob('*') if p.suffix in {'.py','.md','.yaml'} and '.git' not in str(p) and 'docs/superpowers' not in str(p) and p.is_file() and chr(8212) in p.read_text(errors='ignore')]\nprint(bad or 'CLEAN')\nEOF` + +- [ ] **Step 4: Commit, push, PR** + +```bash +git add examples/ridgeline/run_demo.py README.md +git commit -m "docs: ridgeline demo emits live-formula workbook; README surfaces excel capability" +git push -u origin feat/excel-live-model +gh pr create --base main --title "feat: live-formula Excel export (toolkit + canonical translator + verification)" \ + --body "Native live-formula Excel: pyfpa/excel toolkit, model_to_excel canonical monthly translator (named assumptions, real formulas), verify_workbook harness (formulas evaluator, dev-only dep), model-export CLI, fpa-excel-model skill with the no-unverified-workbook guardrail, CI equivalence proof on Ridgeline + an NOL/debt/seasonality edge config. Cadence variants are agent work composed from the toolkit. Spec: docs/superpowers/specs/2026-06-10-excel-live-model-design.md" +``` + +Expected: PR opened, CI green. Jeff merges. + +--- + +## Self-Review notes + +- **Spec coverage:** toolkit -> Task 1; translator + layout/vocabulary -> Task 2; + verify + lazy import + dev extra -> Task 3; CI equivalence (success criteria + 2 and 3, incl. NOL/term-loan-exhaustion/off-January start) -> Task 4; public + API + CLI -> Task 5; skill + pointers + contract -> Task 6; demo + README + + dash sweep -> Task 7. Error handling: unsupported-config guard lives in the + translator (Task 2 implementation note), missing-formulas message tested in + Task 3, NaN/unevaluated-cells-fail in verify implementation. +- **Type consistency:** `model_to_excel(cfg, path)`, `verify_workbook(path, + expected, *, rel_tol)`, `VerifyReport(passed, failures, max_rel_deviation, + lines_checked)`, toolkit signatures used identically across tasks. +- **Honest uncertainty, flagged where it lives:** the `formulas` solution-key + format (Task 3 note) is verify-against-installed-version work, with the rule + "adjust the lookup, never the assertion bar." diff --git a/docs/superpowers/specs/2026-06-10-excel-live-model-design.md b/docs/superpowers/specs/2026-06-10-excel-live-model-design.md new file mode 100644 index 0000000..1ae39a7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-excel-live-model-design.md @@ -0,0 +1,162 @@ +# Live-Formula Excel Export: Design + +**Status:** Approved (design phase) +**Date:** 2026-06-10 +**Topic:** Make "export my model to Excel with real working formulas" a native +toolbelt capability: a tested formula toolkit, one canonical monthly translator, +and a verification harness that proves any generated workbook reproduces the +engine. Cadence and layout stay agent territory. + +## Why + +Every finance professional ultimately wants the same artifact: a workbook where +changing an assumption recalculates the model. Today `forecast_to_excel` dumps +static values. A bare agent asked for "Excel with formulas" would improvise an +unverified workbook with whatever library it guessed at. The toolbelt should +hand the agent the writer, the building blocks, and a proof loop, so the +workbook that reaches a CFO is generated from the model structure and verified +against the engine, not pasted values or vibes. + +Key architectural fact: live formulas cannot be derived from a DataFrame +(values carry no formula semantics). They must be compiled from the model +structure, which only the kernel knows. This is therefore a second compile +target for `EntityConfig`, and it belongs in the kernel. + +## Decisions (locked) + +| Decision | Choice | +| --- | --- | +| Writer library | **openpyxl** (already a runtime dependency; formulas as strings, named ranges, number formats; pure Python, CI-safe). | +| Evaluator library | **formulas** (PyPI name `formulas`), used ONLY for verification. Dev extra, not a runtime dependency. `xlcalculator` noted as fallback if `formulas` is ever abandoned. | +| Rejected | xlwings (requires a running Excel app; breaks CI and self-hosted ethos). XlsxWriter (second writer dependency, no new capability). | +| Kernel scope | Toolkit + ONE canonical translator (the standard monthly engine model) + verification harness. | +| Cadence | **Not a kernel concern.** Quarterly, weekly, per-segment, lender views: the agent generates company-specific exporters from the toolkit, registers them as entrypoints, and verifies them with the same harness. The user changes cadence by asking the AI. | +| Formula vocabulary | Restricted by design to arithmetic, `^`, `SUM`, `MIN`, `MAX`, `IF`. Everything the engine model needs, and everything the `formulas` evaluator handles reliably. | +| Verification bar | A workbook reproduces `cashflow_from_config` line by line, month by month, within 1e-6 relative. Enforced in CI on the Ridgeline config. | + +## Components: `pyfpa/excel/` (new package, small files) + +`pyfpa/excel/toolkit.py` (the composable pieces) +- `add_named_cell(ws, name, value, *, workbook)` and + `add_named_row(ws, name, values, ...)`: write an assumption cell or row and + register a workbook-scoped defined name for it. +- `formula_row(ws, row_label, template, n_cols, ...)`: fill a model row with a + formula template referencing named assumptions and relative prior-column + cells (e.g. running balances). +- Number-format helpers (money, percent, days) and a frozen-pane/header helper. +- Pure openpyxl; no engine imports. This is what generated exporters compose. + +`pyfpa/excel/model_workbook.py` (the canonical translator) +- `model_to_excel(cfg: EntityConfig, path) -> None`: compile the standard + monthly model to a two-sheet workbook. + - **Assumptions sheet**: named cells/rows for every `EntityConfig` driver: + per-channel annual revenue, growth rate, COGS pct, 12 seasonality weights; + opex lines (kind, amount, pct); `dso_days`/`dio_days`/`dpo_days`; + debt instruments (opening balance, annual rate, monthly principal); + `tax_rate`, opening balances incl. NOL; `da_monthly`, `capex_monthly`. + - **Model sheet**: months across columns (1..horizon), the full + `cashflow_from_config` line stack down rows, every cell a formula: + per-channel revenue (annual x normalized seasonality x growth^year), + COGS, opex lines, EBITDA, D&A, term-loan/LOC balance rows with interest, + NOL opening/used/closing rows feeding tax, net income, AR/AP/inventory + balance and delta rows, wc cash impact, OCF, capex, FCF, principal, + change in cash, ending cash (cumulative). + - First-month edge cases (deltas vs opening balances) handled explicitly. + +`pyfpa/excel/verify.py` (the proof loop) +- `verify_workbook(path, expected: pd.DataFrame, *, rel_tol=1e-6) -> VerifyReport`: + evaluate the workbook with the `formulas` library, extract the model-sheet + lines, compare to the expected frame per line per month. `VerifyReport` is a + pydantic model (per-line max deviation, pass/fail, any unevaluated cells). +- Imports `formulas` lazily with a clear error naming the install command + (`pip install formulas`) so the runtime package never requires it. + +`pyfpa/excel/__init__.py` re-exports; `pyfpa/__init__.py` exposes +`model_to_excel` and `verify_workbook`. + +## Surfaces + +- **Public API:** `pyfpa.model_to_excel`, `pyfpa.verify_workbook`. +- **CLI:** `model-export --config --out ` as a thin + handler in the existing `cli_commands/` style (JSON output, module-form + hints). Verification stays a Python/test concern, not a CLI command, in v1. +- **Contract (`AGENTS.md` + `CLAUDE.md`):** a short section: when the user + wants live-formula Excel, use `model_to_excel` for the standard monthly + model; for any other cadence or layout, generate a company-specific exporter + in the generated namespace using `pyfpa.excel.toolkit`, register it with + `entrypoint-register` (kind `report`), install `formulas`, and run + `verify_workbook` before delivering. No workbook ships unverified. +- **Skill (new): `skills/fpa-excel-model/SKILL.md`.** Triggers on the way users + actually ask ("export this to Excel", "a workbook with working formulas", + "something I can hand my board that recalculates"). Encodes the workflow: + use `model_to_excel` for the standard monthly model; for any other cadence + or layout, generate a company-specific exporter in the generated namespace + composing `pyfpa.excel.toolkit`, register it with `entrypoint-register` + (kind `report`); always `pip install formulas` and run `verify_workbook` + before delivering. Guardrails: no workbook ships unverified; formulas stay + within the supported vocabulary; the workbook is generated from the model + structure, never hand-edited values. `fpa-board-briefing` and + `fpa-scaffold-model` get one-line pointers to it. +- **Ridgeline example:** `run_demo.py` also emits `model.xlsx` so a downloader + opens a working formula model two commands after cloning. + +## How it ties into the repo flow + +1. User downloads the repo, opens it with Claude Code or Codex. +2. The contract and skills tell the agent the capability exists and how to + prove its output. +3. "Give me this in Excel I can hand my board": agent calls `model_to_excel`. +4. "Make it quarterly with a tab per segment": agent writes + `models/generated//excel_quarterly.py` composing + `pyfpa.excel.toolkit`, registers the entrypoint, installs `formulas`, runs + `verify_workbook` against the engine frame, and only then delivers. +5. CI keeps the canonical translator honest forever via the Ridgeline + equivalence test. + +## Error handling + +- `model_to_excel` raises a clear `ValueError` for configs it cannot express + (none expected for the current schema; the guard exists for future fields, + naming the unsupported field). +- `verify_workbook` raises a clear `RuntimeError` if `formulas` is missing, + with the install command in the message. +- Unevaluated cells or NaNs in verification are failures, never skipped. + +## Success criteria + +1. `model_to_excel(load_config("examples/ridgeline/config.yaml"), path)` + produces a workbook that opens in Excel/LibreOffice with editable named + assumptions, and changing an assumption recalculates the model. +2. CI proves the Ridgeline workbook reproduces `cashflow_from_config` line by + line, month by month, within 1e-6 relative, via `verify_workbook`. +3. An edge config exercising NOL consumption, a term loan plus LOC, and + multi-channel seasonality also verifies. +4. The toolkit is importable and documented well enough that a generated + exporter composes it without copying kernel internals. +5. Runtime dependencies unchanged (openpyxl only); `formulas` confined to the + dev extra. +6. The `fpa-excel-model` skill exists, triggers on Excel-shaped requests, and + every `pyfpa.*` call it references resolves (the phantom-call audit bar). +7. Contract, skills, README updated; no em dashes; suite green. + +## Testing + +- Structure tests: defined names exist; model-sheet cells contain formulas + (strings starting with `=`), not values; frozen panes and number formats set. +- Equivalence tests (require `formulas`, included in dev extras so CI runs + them): Ridgeline config and the NOL/debt/seasonality edge config. +- Toolkit unit tests: named cell/row registration, formula_row templating, + first-column vs subsequent-column references. +- `verify_workbook` failure path: a deliberately corrupted formula produces a + failing `VerifyReport`, and the missing-`formulas` error message names the + install command (tested with an import shim). +- CLI test for `model-export` in the established CLI test style. + +## Scope boundaries (YAGNI) + +- No 13-week cash sheet in the kernel translator (the agent can generate one; + the weekly schedule expands fine through the toolkit). +- No charts, no VBA or macros, no xlwings, no template-file system. +- No styling pass beyond number formats, bold headers, frozen panes. +- No quarterly/weekly kernel variants, deliberately: cadence is agent work. +- `forecast_to_excel` (static values) remains unchanged for plain data dumps. diff --git a/examples/ridgeline/run_demo.py b/examples/ridgeline/run_demo.py index 76f8a29..b556d50 100644 --- a/examples/ridgeline/run_demo.py +++ b/examples/ridgeline/run_demo.py @@ -17,6 +17,7 @@ import pyfpa from pyfpa.io.loaders import load_cash13_config from pyfpa.io.reporting import forecast_to_excel, to_briefing_md +from pyfpa.excel.model_workbook import model_to_excel _TITLE = "Ridgeline Chair Co." @@ -37,6 +38,9 @@ def run_demo(output_dir: str | Path) -> dict: (out / "briefing.md").write_text(briefing) forecast_to_excel(monthly, out / "forecast.xlsx") + cfg = pyfpa.load_config(_HERE / "config.yaml") + model_to_excel(cfg, out / "model.xlsx") + return { "revenue_total": round(monthly["revenue"].sum()), "ebitda_total": round(monthly["ebitda"].sum()), @@ -51,5 +55,6 @@ def run_demo(output_dir: str | Path) -> dict: if __name__ == "__main__": figures = run_demo(_REPO_ROOT / "docs/demo") print(f"Wrote briefing.md + forecast.xlsx to docs/demo/") + print(f"Wrote model.xlsx (live-formula workbook) to docs/demo/") for key, value in figures.items(): print(f" {key}: {value}") diff --git a/pyfpa/__init__.py b/pyfpa/__init__.py index f125d09..82f3591 100644 --- a/pyfpa/__init__.py +++ b/pyfpa/__init__.py @@ -22,6 +22,8 @@ from pyfpa.analysis.reconcile import reconcile from pyfpa.io.pl_csv import read_pl_csv from pyfpa.io.reporting import to_briefing_md, forecast_to_excel +from pyfpa.excel.model_workbook import model_to_excel +from pyfpa.excel.verify import verify_workbook from pyfpa.backtest import ( ScoreResult, score_forecast, Snapshot, snapshot_forecast, save_snapshot, load_snapshot, holdout_backtest, @@ -68,6 +70,7 @@ "working_capital_from_config", "debt_from_config", "cashflow_from_config", "Cash13Config", "WeeklyFlow", "expand_flow", "cash13_forecast", "runway_summary", "load_cash13_config", "read_pl_csv", "to_briefing_md", "forecast_to_excel", + "model_to_excel", "verify_workbook", "Sku", "sku_profitability", "pareto_breakpoint", "load_skus", "Segment", "segment_pnl", "roll_up_segments", "segments_to_channels", "Carveout", "divest", "net_debt_to_ebitda", "reconcile", diff --git a/pyfpa/cli.py b/pyfpa/cli.py index 42d1891..4d02d9b 100644 --- a/pyfpa/cli.py +++ b/pyfpa/cli.py @@ -25,6 +25,7 @@ command_onboarding_render, command_scorecard_render, ) +from pyfpa.cli_commands.reporting import command_model_export from pyfpa.cli_commands.lineage import ( command_connector_list, command_connector_scaffold, @@ -549,6 +550,15 @@ def build_parser() -> JsonArgumentParser: connector_validate_parser.add_argument("--timeout", type=float, default=30.0) connector_validate_parser.set_defaults(handler=command_connector_validate) + model_export_parser = subparsers.add_parser( + "model-export", + help="Generate a live-formula Excel workbook from an EntityConfig YAML", + ) + model_export_parser.add_argument("path", nargs="?", default=".") + model_export_parser.add_argument("--config", required=True) + model_export_parser.add_argument("--out", required=True) + model_export_parser.set_defaults(handler=command_model_export) + doctor_parser = subparsers.add_parser("doctor", help="Validate workspace contracts") doctor_parser.add_argument("path", nargs="?", default=".") doctor_parser.set_defaults(handler=command_doctor) diff --git a/pyfpa/cli_commands/reporting.py b/pyfpa/cli_commands/reporting.py new file mode 100644 index 0000000..170f495 --- /dev/null +++ b/pyfpa/cli_commands/reporting.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import argparse + +from openpyxl import load_workbook + +from pyfpa.cli_helpers import _failure, _root, _success +from pyfpa.memory.workspace import workspace_path + + +def command_model_export(args: argparse.Namespace) -> int: + from pyfpa.config.loader import load_config + from pyfpa.excel.model_workbook import model_to_excel + + root = _root(args.path) + workspace = workspace_path(root) + if not workspace.is_dir(): + return _failure( + "model-export", + root, + "workspace_not_initialized", + "initialize the company workspace before exporting a model workbook", + ) + try: + cfg = load_config(args.config) + except Exception as exc: + return _failure("model-export", root, "invalid_config", str(exc)) + try: + out_path = _root(args.out) + model_to_excel(cfg, out_path) + wb = load_workbook(out_path) + sheet_names = wb.sheetnames + except Exception as exc: + return _failure("model-export", root, "export_failed", str(exc)) + return _success( + "model-export", + root, + { + "workbook_path": str(out_path), + "sheets": sheet_names, + "config": str(args.config), + "entity": cfg.name, + }, + ) diff --git a/pyfpa/excel/__init__.py b/pyfpa/excel/__init__.py new file mode 100644 index 0000000..baaf3c0 --- /dev/null +++ b/pyfpa/excel/__init__.py @@ -0,0 +1,12 @@ +from pyfpa.excel.toolkit import ( + add_named_cell, add_named_row, fill_formula_row, freeze_header, + money_format, percent_format, days_format, +) +from pyfpa.excel.model_workbook import model_to_excel +from pyfpa.excel.verify import VerifyReport, verify_workbook + +__all__ = [ + "add_named_cell", "add_named_row", "fill_formula_row", "freeze_header", + "money_format", "percent_format", "days_format", + "model_to_excel", "VerifyReport", "verify_workbook", +] diff --git a/pyfpa/excel/model_workbook.py b/pyfpa/excel/model_workbook.py new file mode 100644 index 0000000..2b5530e --- /dev/null +++ b/pyfpa/excel/model_workbook.py @@ -0,0 +1,406 @@ +"""Canonical monthly translator: EntityConfig -> two-sheet live-formula workbook. + +Assumptions sheet: named driver cells (editable). +Model sheet: every line a formula referencing those names. +Formula vocabulary: arithmetic, ^, SUM, MIN, MAX, IF only. +""" +from __future__ import annotations + +from pathlib import Path +from typing import NamedTuple + +from openpyxl import Workbook +from openpyxl.utils import get_column_letter + +from pyfpa.config.schemas import EntityConfig +from pyfpa.excel.toolkit import ( + add_named_cell, + add_named_row, + days_format, + fill_formula_row, + freeze_header, + money_format, + percent_format, +) +from pyfpa.models.periods import month_index + + +class _SeasonRef(NamedTuple): + name: str # defined-name "seasonality_ch{i}" + col_letters: list[str] # 12 Assumptions column letters (B onward) + row: int + + +class _ChannelRef(NamedTuple): + name_rev: str # "rev_annual_ch{i}" + name_growth: str # "growth_ch{i}" + name_cogs: str # "cogs_pct_ch{i}" + seasonality: _SeasonRef + + +class _DebtRef(NamedTuple): + name_open: str # "debt_open_{k}" + name_rate: str # "debt_rate_{k}" + name_prin: str # "debt_prin_{k}" (term_loan only; "" for loc) + kind: str + + +_ASSUMP_COL = 2 # column B for assumption values +_SEASON_COL = 2 # seasonality rows also start at column B + + +def _build_assumptions( + wb: Workbook, cfg: EntityConfig +) -> tuple[list[_ChannelRef], list[str], list[_DebtRef]]: + """Populate Assumptions sheet and return per-section refs.""" + ws = wb["Assumptions"] + row = 2 # row 1 used as title + + ws.cell(row=1, column=1, value="Assumptions") + + def nc(name: str, val: float, fmt: str) -> None: + nonlocal row + ws.cell(row=row, column=1, value=name) + add_named_cell(wb, ws, name=name, row=row, col=_ASSUMP_COL, value=val, number_format=fmt) + row += 1 + + def nr(name: str, vals: list[float]) -> _SeasonRef: + nonlocal row + ws.cell(row=row, column=1, value=name) + add_named_row(wb, ws, name=name, row=row, start_col=_SEASON_COL, values=vals) + ref = _SeasonRef( + name=name, + col_letters=[get_column_letter(_SEASON_COL + j) for j in range(12)], + row=row, + ) + row += 1 + return ref + + mfmt = money_format() + pfmt = percent_format() + dfmt = days_format() + + channel_refs: list[_ChannelRef] = [] + for i, ch in enumerate(cfg.channels, start=1): + nc(f"rev_annual_ch{i}", ch.annual_revenue, mfmt) + nc(f"growth_ch{i}", ch.growth_rate, pfmt) + nc(f"cogs_pct_ch{i}", ch.cogs_pct, pfmt) + sr = nr(f"seasonality_ch{i}", list(ch.seasonality)) + channel_refs.append(_ChannelRef( + name_rev=f"rev_annual_ch{i}", + name_growth=f"growth_ch{i}", + name_cogs=f"cogs_pct_ch{i}", + seasonality=sr, + )) + + opex_names: list[str] = [] + for j, line in enumerate(cfg.opex, start=1): + if line.kind == "fixed": + nm = f"opex_amount_{j}" + nc(nm, line.monthly_amount, mfmt) + else: + nm = f"opex_pct_{j}" + nc(nm, line.pct_of_revenue, pfmt) + opex_names.append(nm) + + for name, val, fmt in [ + ("dso_days", cfg.working_capital.dso_days, dfmt), + ("dio_days", cfg.working_capital.dio_days, dfmt), + ("dpo_days", cfg.working_capital.dpo_days, dfmt), + ("tax_rate", cfg.tax_rate, pfmt), + ("da_monthly", cfg.da_monthly, mfmt), + ("capex_monthly", cfg.capex_monthly, mfmt), + ("open_cash", cfg.opening_balances.cash, mfmt), + ("open_ar", cfg.opening_balances.ar, mfmt), + ("open_ap", cfg.opening_balances.ap, mfmt), + ("open_inventory", cfg.opening_balances.inventory, mfmt), + ("open_nol", cfg.opening_balances.nol, mfmt), + ]: + nc(name, val, fmt) + + debt_refs: list[_DebtRef] = [] + for k, inst in enumerate(cfg.debt, start=1): + nc(f"debt_open_{k}", inst.opening_balance, mfmt) + nc(f"debt_rate_{k}", inst.annual_rate, pfmt) + nm_prin = "" + if inst.kind == "term_loan": + nm_prin = f"debt_prin_{k}" + nc(nm_prin, inst.monthly_principal, mfmt) + debt_refs.append(_DebtRef( + name_open=f"debt_open_{k}", + name_rate=f"debt_rate_{k}", + name_prin=nm_prin, + kind=inst.kind, + )) + + return channel_refs, opex_names, debt_refs + + +_MODEL_START_COL = 2 # column B = first month + + +def _season_cell(sr: _SeasonRef, cal_month_0: int) -> str: + """Absolute cell reference for a 0-based calendar month's seasonality weight.""" + return f"Assumptions!${sr.col_letters[cal_month_0]}${sr.row}" + + +def _build_model( + wb: Workbook, + cfg: EntityConfig, + channel_refs: list[_ChannelRef], + opex_names: list[str], + debt_refs: list[_DebtRef], +) -> None: + ws = wb["Model"] + idx = month_index(cfg.start_month, cfg.horizon_months) + n = cfg.horizon_months + sc = _MODEL_START_COL + mfmt = money_format() + + row_counter = [2] # start at row 2; row 1 is headers + + def alloc() -> int: + r = row_counter[0] + row_counter[0] += 1 + return r + + def emit_fn(label: str, template, fmt: str = mfmt) -> int: + r = alloc() + fill_formula_row(ws, row=r, label=label, start_col=sc, n_cols=n, + template=template, number_format=fmt) + return r + + def emit_cells(label: str, formulas: list[str], fmt: str = mfmt) -> int: + """Write per-month cells directly (used when formula depends on own row).""" + r = alloc() + ws.cell(row=r, column=1, value=label) + for m_idx, formula in enumerate(formulas): + ws.cell(row=r, column=sc + m_idx, value=formula).number_format = fmt + return r + + # Header row 1: month labels + ws.cell(row=1, column=1, value="") + for m_idx, period in enumerate(idx): + ws.cell(row=1, column=sc + m_idx, value=str(period)) + + # -- Per-channel revenue -- + # Bake (weight_cell_ref, year_exponent) per month per channel + ch_rev_rows: list[int] = [] + for i, (ch, cref) in enumerate(zip(cfg.channels, channel_refs)): + baked = [ + (_season_cell(cref.seasonality, period.month - 1), m_idx // 12) + for m_idx, period in enumerate(idx) + ] + + def make_rev(cref_=cref, baked_=baked): + def t(m: int, col: str) -> str: + wref, yexp = baked_[m - 1] + return ( + f"={cref_.name_rev}" + f"*({wref}/SUM({cref_.seasonality.name}))" + f"*(1+{cref_.name_growth})^{yexp}" + ) + return t + + ch_rev_rows.append(emit_fn(f"revenue_ch{i + 1}", make_rev())) + + # -- Revenue total -- + r_rev = emit_fn( + "revenue", + lambda m, col: f"=SUM({col}{ch_rev_rows[0]}:{col}{ch_rev_rows[-1]})", + ) + + # -- COGS total (explicit product sum across channels) -- + r_cogs = emit_fn( + "cogs", + lambda m, col: "=" + "+".join( + f"{col}{ch_rev_rows[i]}*{cref.name_cogs}" + for i, cref in enumerate(channel_refs) + ), + ) + + # -- Gross profit -- + r_gp = emit_fn("gross_profit", lambda m, col: f"={col}{r_rev}-{col}{r_cogs}") + + # -- Opex per line then total -- + opex_rows: list[int] = [] + for j, (line, nm) in enumerate(zip(cfg.opex, opex_names)): + if line.kind == "fixed": + opex_rows.append(emit_fn(f"opex_{j + 1}", lambda m, col, nm_=nm: f"={nm_}")) + else: + opex_rows.append(emit_fn( + f"opex_{j + 1}", + lambda m, col, nm_=nm, r=r_rev: f"={col}{r}*{nm_}", + )) + + if opex_rows: + r_opex = emit_fn( + "opex", + lambda m, col: f"=SUM({col}{opex_rows[0]}:{col}{opex_rows[-1]})", + ) + else: + r_opex = emit_fn("opex", lambda m, col: "=0") + + # -- EBITDA and D&A -- + r_ebitda = emit_fn("ebitda", lambda m, col: f"={col}{r_gp}-{col}{r_opex}") + r_da = emit_fn("da", lambda m, col: "=da_monthly") + + # -- Per-instrument debt rows (balance, interest, principal) -- + debt_int_rows: list[int] = [] + debt_prin_rows: list[int] = [] + + for k, (inst, dref) in enumerate(zip(cfg.debt, debt_refs)): + # Balance (AFTER payment) -- references its own row, so allocate then fill directly + bal_row = alloc() + ws.cell(row=bal_row, column=1, value=f"debt_balance_{k + 1}") + for m_idx in range(n): + if inst.kind == "term_loan": + if m_idx == 0: + formula = f"={dref.name_open}-MIN({dref.name_prin},{dref.name_open})" + else: + pc = get_column_letter(sc + m_idx - 1) + formula = f"={pc}{bal_row}-MIN({dref.name_prin},{pc}{bal_row})" + else: + if m_idx == 0: + formula = f"={dref.name_open}" + else: + pc = get_column_letter(sc + m_idx - 1) + formula = f"={pc}{bal_row}" + ws.cell(row=bal_row, column=sc + m_idx, value=formula).number_format = mfmt + + # Interest (on PRE-payment balance = prior balance or opening) + int_formulas = [ + f"={dref.name_open}*{dref.name_rate}/12" if m_idx == 0 + else f"={get_column_letter(sc + m_idx - 1)}{bal_row}*{dref.name_rate}/12" + for m_idx in range(n) + ] + int_row = emit_cells(f"interest_{k + 1}", int_formulas) + debt_int_rows.append(int_row) + + # Principal + prin_formulas: list[str] = [] + for m_idx in range(n): + if inst.kind != "term_loan": + prin_formulas.append("=0") + elif m_idx == 0: + prin_formulas.append(f"=MIN({dref.name_prin},{dref.name_open})") + else: + pc = get_column_letter(sc + m_idx - 1) + prin_formulas.append(f"=MIN({dref.name_prin},{pc}{bal_row})") + prin_row = emit_cells(f"principal_{k + 1}", prin_formulas) + debt_prin_rows.append(prin_row) + + # -- Interest and principal totals -- + if debt_int_rows: + r_int = emit_fn( + "interest", + lambda m, col: "=" + "+".join(f"{col}{r}" for r in debt_int_rows), + ) + else: + r_int = emit_fn("interest", lambda m, col: "=0") + + if debt_prin_rows: + r_prin = emit_fn( + "principal", + lambda m, col: "=" + "+".join(f"{col}{r}" for r in debt_prin_rows), + ) + else: + r_prin = emit_fn("principal", lambda m, col: "=0") + + # -- Pretax income (EBIT - interest; EBIT = EBITDA - D&A) -- + r_pretax = emit_fn( + "pretax_income", + lambda m, col: f"={col}{r_ebitda}-{col}{r_da}-{col}{r_int}", + ) + + # -- NOL: three rows that cross-reference each other and prior month -- + # Allocate all three rows first, then fill + nol_open_row = alloc() + nol_used_row = alloc() + nol_close_row = alloc() + ws.cell(row=nol_open_row, column=1, value="nol_opening") + ws.cell(row=nol_used_row, column=1, value="nol_used") + ws.cell(row=nol_close_row, column=1, value="nol_closing") + + for m_idx in range(n): + cl = get_column_letter(sc + m_idx) + # nol_opening + nol_o = "=open_nol" if m_idx == 0 else f"={get_column_letter(sc + m_idx - 1)}{nol_close_row}" + ws.cell(row=nol_open_row, column=sc + m_idx, value=nol_o).number_format = mfmt + # nol_used + ws.cell( + row=nol_used_row, column=sc + m_idx, + value=f"=MIN({cl}{nol_open_row},MAX(0,{cl}{r_pretax}))", + ).number_format = mfmt + # nol_closing + ws.cell( + row=nol_close_row, column=sc + m_idx, + value=f"={cl}{nol_open_row}-{cl}{nol_used_row}", + ).number_format = mfmt + + # -- Tax, net income -- + r_tax = emit_fn( + "tax", + lambda m, col: f"=(MAX(0,{col}{r_pretax})-{col}{nol_used_row})*tax_rate", + ) + r_ni = emit_fn("net_income", lambda m, col: f"={col}{r_pretax}-{col}{r_tax}") + + # -- Working capital balances -- + r_ar = emit_fn("ar_balance", lambda m, col: f"={col}{r_rev}*dso_days/30") + r_ap = emit_fn("ap_balance", lambda m, col: f"={col}{r_cogs}*dpo_days/30") + r_inv = emit_fn("inv_balance", lambda m, col: f"={col}{r_cogs}*dio_days/30") + + # WC cash impact (first-month references opening balances) + wc_formulas: list[str] = [] + for m_idx in range(n): + cl = get_column_letter(sc + m_idx) + if m_idx == 0: + wc_formulas.append( + f"=-({cl}{r_ar}-open_ar)" + f"+({cl}{r_ap}-open_ap)" + f"-({cl}{r_inv}-open_inventory)" + ) + else: + pc = get_column_letter(sc + m_idx - 1) + wc_formulas.append( + f"=-({cl}{r_ar}-{pc}{r_ar})" + f"+({cl}{r_ap}-{pc}{r_ap})" + f"-({cl}{r_inv}-{pc}{r_inv})" + ) + r_wc = emit_cells("wc_cash_impact", wc_formulas) + + # -- Operating cash flow, capex, FCF, change in cash -- + r_ocf = emit_fn( + "operating_cash_flow", + lambda m, col: f"={col}{r_ni}+{col}{r_da}+{col}{r_wc}", + ) + r_capex = emit_fn("capex", lambda m, col: "=capex_monthly") + r_fcf = emit_fn("free_cash_flow", lambda m, col: f"={col}{r_ocf}-{col}{r_capex}") + r_chg = emit_fn("change_in_cash", lambda m, col: f"={col}{r_fcf}-{col}{r_prin}") + + # Ending cash (cumulative, references own prior cell) + end_row = alloc() + ws.cell(row=end_row, column=1, value="ending_cash") + for m_idx in range(n): + cl = get_column_letter(sc + m_idx) + if m_idx == 0: + formula = f"=open_cash+{cl}{r_chg}" + else: + pc = get_column_letter(sc + m_idx - 1) + formula = f"={pc}{end_row}+{cl}{r_chg}" + ws.cell(row=end_row, column=sc + m_idx, value=formula).number_format = mfmt + + freeze_header(ws, first_data_cell="B2") + + +def model_to_excel(cfg: EntityConfig, path: str | Path) -> None: + """Compile an EntityConfig into a two-sheet live-formula workbook at `path`.""" + wb = Workbook() + ws_assump = wb.active + ws_assump.title = "Assumptions" + wb.create_sheet("Model") + + channel_refs, opex_names, debt_refs = _build_assumptions(wb, cfg) + _build_model(wb, cfg, channel_refs, opex_names, debt_refs) + + wb.save(Path(path)) diff --git a/pyfpa/excel/toolkit.py b/pyfpa/excel/toolkit.py new file mode 100644 index 0000000..52957de --- /dev/null +++ b/pyfpa/excel/toolkit.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence + +from openpyxl.utils import get_column_letter +from openpyxl.workbook import Workbook +from openpyxl.workbook.defined_name import DefinedName +from openpyxl.worksheet.worksheet import Worksheet + + +def money_format() -> str: + return "#,##0" + + +def percent_format() -> str: + return "0.0%" + + +def days_format() -> str: + return "0.0" + + +def add_named_cell( + wb: Workbook, + ws: Worksheet, + *, + name: str, + row: int, + col: int, + value: float, + number_format: str | None = None, +) -> None: + """Write a value and register a workbook-scoped defined name for the cell.""" + cell = ws.cell(row=row, column=col, value=value) + if number_format: + cell.number_format = number_format + ref = f"'{ws.title}'!${get_column_letter(col)}${row}" + wb.defined_names[name] = DefinedName(name=name, attr_text=ref) + + +def add_named_row( + wb: Workbook, + ws: Worksheet, + *, + name: str, + row: int, + start_col: int, + values: Sequence[float], + number_format: str | None = None, +) -> None: + """Write a horizontal run of values and register a defined name for the range.""" + for offset, value in enumerate(values): + cell = ws.cell(row=row, column=start_col + offset, value=value) + if number_format: + cell.number_format = number_format + first = f"${get_column_letter(start_col)}${row}" + last = f"${get_column_letter(start_col + len(values) - 1)}${row}" + wb.defined_names[name] = DefinedName( + name=name, attr_text=f"'{ws.title}'!{first}:{last}" + ) + + +def fill_formula_row( + ws: Worksheet, + *, + row: int, + label: str, + start_col: int, + n_cols: int, + template: Callable[[int, str], str], + number_format: str | None = None, +) -> None: + """Label column A, then fill each month cell with template(month_index, column_letter). + + template receives the 1-based month index and that month's column letter and + must return a full formula string starting with '='.""" + ws.cell(row=row, column=1, value=label) + for m in range(1, n_cols + 1): + col = get_column_letter(start_col + m - 1) + cell = ws.cell(row=row, column=start_col + m - 1, value=template(m, col)) + if number_format: + cell.number_format = number_format + + +def freeze_header(ws: Worksheet, *, first_data_cell: str = "B2") -> None: + ws.freeze_panes = first_data_cell diff --git a/pyfpa/excel/verify.py b/pyfpa/excel/verify.py new file mode 100644 index 0000000..46f3db8 --- /dev/null +++ b/pyfpa/excel/verify.py @@ -0,0 +1,106 @@ +"""Workbook verification harness: evaluate live-formula workbook and compare to engine. + +The ``formulas`` package is a dev-only dependency; it is imported lazily so +the runtime library works without it. +""" +from __future__ import annotations + +import math +from pathlib import Path + +import pandas as pd +from openpyxl import load_workbook +from openpyxl.utils import get_column_letter +from pydantic import BaseModel + + +class VerifyReport(BaseModel): + passed: bool + failures: list[str] + max_rel_deviation: float + lines_checked: int + + +def _load_formulas(): + try: + import formulas # noqa: PLC0415 (lazy on purpose: dev-only dependency) + except ImportError as exc: + raise RuntimeError( + "workbook verification requires the 'formulas' package: pip install formulas" + ) from exc + return formulas + + +def verify_workbook( + path: str | Path, + expected: pd.DataFrame, + *, + rel_tol: float = 1e-6, +) -> VerifyReport: + """Evaluate the workbook's formulas in Python and compare every Model-sheet + line that matches a column of ``expected``, month by month. + + NaN or unevaluated cells are failures, never skipped. + + Key format confirmed against formulas 1.3.4: + ``'[]MODEL'!`` + The sheet name is stored as-uppercase in the solution dict. + """ + formulas = _load_formulas() + path = Path(path) + solution = formulas.ExcelModel().loads(str(path)).finish().calculate() + + wb = load_workbook(path) + model_ws = wb["Model"] + labels = { + model_ws.cell(row=r, column=1).value: r + for r in range(2, model_ws.max_row + 1) + } + + # The formulas library preserves the original filename casing for the book + # part and uppercases the sheet name. Confirmed format: "'[m.xlsx]MODEL'!B3" + book_tag = f"[{path.name}]MODEL" + + failures: list[str] = [] + max_dev = 0.0 + lines = 0 + + for line in expected.columns: + if line not in labels: + continue + lines += 1 + row = labels[line] + for m_idx in range(len(expected.index)): + col_letter = get_column_letter(2 + m_idx) + ref = f"'[{path.name}]MODEL'!{col_letter}{row}" + if ref not in solution: + failures.append(f"{line} month {m_idx + 1}: cell not evaluated (key {ref!r} missing)") + continue + raw = solution[ref].value[0][0] + if raw is None or (isinstance(raw, float) and math.isnan(raw)): + failures.append(f"{line} month {m_idx + 1}: workbook returned NaN/None") + continue + if not isinstance(raw, (int, float)): + # Excel error sentinels (#DIV/0!, #VALUE!, ...) arrive as XlError + # objects. An errored cell is a failed verification, never a crash. + failures.append(f"{line} month {m_idx + 1}: workbook returned error {raw!s}") + continue + got = float(raw) + want = float(expected[line].iloc[m_idx]) + # Hybrid tolerance: relative above magnitude 1, absolute below it, + # so zero-valued lines (e.g. tax with full NOL cover) stay checkable. + denom = max(abs(want), 1.0) + dev = abs(got - want) / denom + max_dev = max(max_dev, dev) + if not dev <= rel_tol: + failures.append( + f"{line} month {m_idx + 1}: workbook {got!r} vs engine {want!r}" + f" (rel_dev={dev:.2e})" + ) + + return VerifyReport( + passed=not failures and lines > 0, + failures=failures, + max_rel_deviation=max_dev, + lines_checked=lines, + ) diff --git a/pyproject.toml b/pyproject.toml index 028b574..e9572db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ ] [project.optional-dependencies] -dev = ["pytest>=8.0", "build>=1.2"] +dev = ["pytest>=8.0", "build>=1.2", "formulas>=1.2"] [project.scripts] openfpa = "pyfpa.cli:main" diff --git a/skills/fpa-board-briefing/SKILL.md b/skills/fpa-board-briefing/SKILL.md index 624e093..0754ee3 100644 --- a/skills/fpa-board-briefing/SKILL.md +++ b/skills/fpa-board-briefing/SKILL.md @@ -37,6 +37,7 @@ Turn forecast output into a board/investor-grade briefing: the headline numbers, `to_briefing_md` emits a headline (revenue, EBITDA, net income, ending cash), an optional 13-week runway section, and a monthly table. 4. **Add the narrative** the renderer can't. `to_briefing_md` emits only the headline, the optional runway section, and the monthly table - it has no narrative slot. So author your own markdown *around* it: prepend a `## What changed` section (the 3 things that moved) and append `## Risks` (3 forward risks) and `## The ask` (e.g. "approve a $200K line to bridge the spring build"). The rendered briefing is the data spine; you supply the story. 5. **Apply judgment** (see **fpa-cfo-judgment**): caveat any pre-close months, state whether cash is flash or GL, and don't quote `ebitda` as true EBITDA if D&A matters. +6. If the audience wants the model itself, produce the live-formula workbook via **fpa-excel-model** alongside the briefing. ## One-command demo diff --git a/skills/fpa-excel-model/SKILL.md b/skills/fpa-excel-model/SKILL.md new file mode 100644 index 0000000..8e49942 --- /dev/null +++ b/skills/fpa-excel-model/SKILL.md @@ -0,0 +1,75 @@ +--- +name: fpa-excel-model +description: Use when the user wants their model in Excel with real working formulas - "export this to Excel", "a workbook I can hand my board", "something that recalculates when I change an assumption". Produces a live-formula workbook generated from the model structure and verified against the engine before delivery. +--- + +# Live-Formula Excel Model (Operate) + +## Overview + +Finance professionals ultimately want a workbook where changing an assumption +recalculates the model. This skill produces one from the model structure, with +named assumption cells and real formulas, then proves it reproduces the engine +before it ships. Static value dumps stay with `forecast_to_excel`; this skill +is for living models. + +**Core principle:** no workbook ships unverified. The workbook is generated +from the model, never hand-edited values. + +## The standard model + +For the kernel's monthly model, one call: + +```python +import pyfpa +cfg = pyfpa.load_config("config.yaml") +pyfpa.model_to_excel(cfg, "model.xlsx") +``` + +Or via CLI: `python3 -m pyfpa.cli model-export --config config.yaml --out model.xlsx`. + +The workbook has an Assumptions sheet (every driver a named, editable cell: +growth, COGS pct, seasonality, DSO/DIO/DPO, debt, tax, D&A, capex) and a Model +sheet where every line of the monthly P&L and cash flow is a formula +referencing those names. + +## Any other cadence or layout + +Quarterly, weekly, a tab per segment, a lender view: these are YOUR work, not +kernel variants. Compose `pyfpa.excel.toolkit` (`add_named_cell`, +`add_named_row`, `fill_formula_row`, formats) in a company-specific exporter +under the generated namespace (`models/generated//`), then register it: + +```bash +python3 -m pyfpa.cli entrypoint-register --name excel-quarterly --kind report \ + --description "Quarterly live-formula workbook" \ + --command-json '["python3", "models/generated//excel_quarterly.py"]' +``` + +Keep formulas within the supported vocabulary (arithmetic, `^`, `SUM`, `MIN`, +`MAX`, `IF`) so verification works. + +## Verify before delivering (always) + +1. `pip install formulas` (verification-only dependency, not shipped at runtime). +2. ```python + report = pyfpa.verify_workbook("model.xlsx", pyfpa.cashflow_from_config(cfg)) + assert report.passed, report.failures + ``` +3. For a bespoke exporter, verify against the frame your exporter is meant to + reproduce (the engine frame, or its quarterly/weekly aggregation). +4. A failing report means the exporter is wrong. Fix the formulas, never the + tolerance, and never deliver an unverified workbook. + +## Guardrails + +- No workbook ships unverified. +- Generated from model structure; never paste computed values into formula cells. +- Supported formula vocabulary only; if you need a function outside it, restructure + the formula (helper rows are fine) rather than expanding the vocabulary. +- Company-specific exporters live in generated namespaces and get tests like any + other generated code. + +## Next + +Workbook delivered, then **fpa-board-briefing** (the narrative that goes with it). diff --git a/skills/fpa-scaffold-model/SKILL.md b/skills/fpa-scaffold-model/SKILL.md index 9df074f..483db95 100644 --- a/skills/fpa-scaffold-model/SKILL.md +++ b/skills/fpa-scaffold-model/SKILL.md @@ -45,6 +45,8 @@ Turn a company's financials into a runnable `pyfpa` config. Read the business pr - Set `opening_balances` AR/AP/inventory to the **first forecast month's** DSO/DPO/DIO-implied balances - the engine diffs each month against the prior, seeding month 1 against opening, so use month-1 projected revenue/COGS, NOT the annual average. Get this wrong and month-1 cash swings on a one-time artifact (see **fpa-cfo-judgment** working-capital seam). - `"total"` is a reserved channel/opex name (the engine adds a `total` column). +A live-formula Excel edition of the model is available via **fpa-excel-model**. + ## Next Runnable config confirmed → **fpa-configure-actuals** to wire live/real numbers, then the operate skills. diff --git a/tests/test_cli_model_export.py b/tests/test_cli_model_export.py new file mode 100644 index 0000000..4c7828c --- /dev/null +++ b/tests/test_cli_model_export.py @@ -0,0 +1,88 @@ +import json +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def run_cli(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-m", "pyfpa.cli", *args], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + + +def output_json(result: subprocess.CompletedProcess[str]) -> dict: + assert result.stdout, result.stderr + return json.loads(result.stdout) + + +def test_model_export_writes_xlsx_with_both_sheets(tmp_path): + from openpyxl import load_workbook + + assert run_cli("init", str(tmp_path)).returncode == 0 + config_path = ROOT / "examples/ridgeline/config.yaml" + out_path = tmp_path / "model.xlsx" + + result = run_cli( + "model-export", + str(tmp_path), + "--config", + str(config_path), + "--out", + str(out_path), + ) + + assert result.returncode == 0 + payload = output_json(result) + assert payload["ok"] is True + assert payload["command"] == "model-export" + data = payload["data"] + assert set(data["sheets"]) == {"Assumptions", "Model"} + assert out_path.exists() + wb = load_workbook(out_path) + assert set(wb.sheetnames) == {"Assumptions", "Model"} + + +def test_model_export_fails_without_workspace(tmp_path): + config_path = ROOT / "examples/ridgeline/config.yaml" + out_path = tmp_path / "model.xlsx" + + result = run_cli( + "model-export", + str(tmp_path), + "--config", + str(config_path), + "--out", + str(out_path), + ) + + assert result.returncode == 1 + payload = output_json(result) + assert payload["ok"] is False + assert payload["error"]["type"] == "workspace_not_initialized" + + +def test_model_export_fails_on_invalid_config(tmp_path): + assert run_cli("init", str(tmp_path)).returncode == 0 + bad_config = tmp_path / "bad.yaml" + bad_config.write_text("not: valid: entity: config\n") + out_path = tmp_path / "model.xlsx" + + result = run_cli( + "model-export", + str(tmp_path), + "--config", + str(bad_config), + "--out", + str(out_path), + ) + + assert result.returncode == 1 + payload = output_json(result) + assert payload["ok"] is False diff --git a/tests/test_excel_equivalence.py b/tests/test_excel_equivalence.py new file mode 100644 index 0000000..b2f593c --- /dev/null +++ b/tests/test_excel_equivalence.py @@ -0,0 +1,47 @@ +import pytest + +pytest.importorskip("formulas") + +from pyfpa.config.loader import load_config +from pyfpa.config.schemas import EntityConfig +from pyfpa.excel.model_workbook import model_to_excel +from pyfpa.excel.verify import verify_workbook +from pyfpa.models.cashflow import cashflow_from_config + + +def test_ridgeline_workbook_reproduces_engine(tmp_path): + cfg = load_config("examples/ridgeline/config.yaml") + path = tmp_path / "ridgeline.xlsx" + model_to_excel(cfg, path) + report = verify_workbook(path, cashflow_from_config(cfg)) + assert report.passed, report.failures[:10] + assert report.lines_checked >= 10 + + +def test_edge_config_nol_debt_seasonality_reproduces_engine(tmp_path): + cfg = EntityConfig.model_validate({ + "name": "Edge", "start_month": "2026-04", "horizon_months": 24, "tax_rate": 0.25, + "channels": [ + {"name": "A", "annual_revenue": 2_400_000.0, "growth_rate": 0.12, + "seasonality": [1, 1, 2, 3, 2, 1, 1, 1, 2, 3, 4, 3], "cogs_pct": 0.55}, + {"name": "B", "annual_revenue": 900_000.0, "growth_rate": -0.05, + "seasonality": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2], "cogs_pct": 0.35}, + ], + "opex": [ + {"name": "fixed_g_a", "kind": "fixed", "monthly_amount": 80_000.0}, + {"name": "var_mktg", "kind": "variable", "pct_of_revenue": 0.07}, + ], + "debt": [ + {"name": "term", "kind": "term_loan", "opening_balance": 240_000.0, + "annual_rate": 0.09, "monthly_principal": 25_000.0}, + {"name": "loc", "kind": "loc", "opening_balance": 150_000.0, "annual_rate": 0.11}, + ], + "working_capital": {"dso_days": 38.0, "dpo_days": 42.0, "dio_days": 75.0}, + "opening_balances": {"cash": 25_000.0, "ar": 180_000.0, "ap": 110_000.0, + "inventory": 260_000.0, "nol": 500_000.0}, + "da_monthly": 6_000.0, "capex_monthly": 9_000.0, + }) + path = tmp_path / "edge.xlsx" + model_to_excel(cfg, path) + report = verify_workbook(path, cashflow_from_config(cfg)) + assert report.passed, report.failures[:10] diff --git a/tests/test_excel_model_structure.py b/tests/test_excel_model_structure.py new file mode 100644 index 0000000..c473f37 --- /dev/null +++ b/tests/test_excel_model_structure.py @@ -0,0 +1,68 @@ +from openpyxl import load_workbook + +from pyfpa.config.schemas import EntityConfig +from pyfpa.excel.model_workbook import model_to_excel + + +def _cfg(): + return EntityConfig.model_validate({ + "name": "T", "start_month": "2026-01", "horizon_months": 12, "tax_rate": 0.21, + "channels": [ + {"name": "A", "annual_revenue": 1_200_000.0, "growth_rate": 0.10, + "seasonality": [1.0] * 12, "cogs_pct": 0.5}, + {"name": "B", "annual_revenue": 600_000.0, "growth_rate": 0.0, + "seasonality": [1, 1, 1, 1, 1, 1, 2, 2, 2, 1, 1, 1], "cogs_pct": 0.4}, + ], + "opex": [ + {"name": "salaries", "kind": "fixed", "monthly_amount": 30_000.0}, + {"name": "marketing", "kind": "variable", "pct_of_revenue": 0.05}, + ], + "debt": [ + {"name": "term", "kind": "term_loan", "opening_balance": 500_000.0, + "annual_rate": 0.08, "monthly_principal": 10_000.0}, + {"name": "loc", "kind": "loc", "opening_balance": 100_000.0, "annual_rate": 0.10}, + ], + "working_capital": {"dso_days": 45.0, "dpo_days": 30.0, "dio_days": 60.0}, + "opening_balances": {"cash": 50_000.0, "ar": 100_000.0, "ap": 60_000.0, + "inventory": 120_000.0, "nol": 40_000.0}, + "da_monthly": 2_000.0, "capex_monthly": 5_000.0, + }) + + +def test_workbook_has_named_assumptions_and_formula_cells(tmp_path): + path = tmp_path / "model.xlsx" + model_to_excel(_cfg(), path) + wb = load_workbook(path) + assert set(wb.sheetnames) == {"Assumptions", "Model"} + for name in ["rev_annual_ch1", "growth_ch1", "cogs_pct_ch1", "seasonality_ch1", + "rev_annual_ch2", "dso_days", "dio_days", "dpo_days", "tax_rate", + "da_monthly", "capex_monthly", "open_cash", "open_nol", + "debt_open_1", "debt_rate_1", "debt_prin_1", "debt_open_2", + "opex_amount_1", "opex_pct_2"]: + assert name in wb.defined_names, f"missing defined name: {name}" + model = wb["Model"] + labels = [model.cell(row=r, column=1).value for r in range(2, model.max_row + 1)] + # Use engine column names for the aggregate rows (per the plan's label adjustment note) + for required in ["revenue", "gross_profit", "ebitda", "pretax_income", + "tax", "net_income", "wc_cash_impact", "operating_cash_flow", + "free_cash_flow", "change_in_cash", "ending_cash"]: + assert required in labels, f"missing model row: {required}" + # every data cell in a known row is a formula string, not a value + row_idx = labels.index("ending_cash") + 2 + for col in range(2, 2 + 12): + v = model.cell(row=row_idx, column=col).value + assert isinstance(v, str) and v.startswith("="), f"not a formula: {v!r}" + + +def test_formula_vocabulary_is_restricted(tmp_path): + import re + path = tmp_path / "model.xlsx" + model_to_excel(_cfg(), path) + wb = load_workbook(path) + model = wb["Model"] + allowed = {"SUM", "MIN", "MAX", "IF"} + for row in model.iter_rows(min_row=2, min_col=2): + for cell in row: + if isinstance(cell.value, str) and cell.value.startswith("="): + for fn in re.findall(r"([A-Z]{2,})\s*\(", cell.value): + assert fn in allowed, f"forbidden function {fn} in {cell.value}" diff --git a/tests/test_excel_toolkit.py b/tests/test_excel_toolkit.py new file mode 100644 index 0000000..9e7a035 --- /dev/null +++ b/tests/test_excel_toolkit.py @@ -0,0 +1,50 @@ +# tests/test_excel_toolkit.py +from openpyxl import Workbook, load_workbook + +from pyfpa.excel.toolkit import ( + add_named_cell, add_named_row, fill_formula_row, money_format, percent_format, +) + + +def test_add_named_cell_registers_defined_name(tmp_path): + wb = Workbook() + ws = wb.active + ws.title = "Assumptions" + add_named_cell(wb, ws, name="tax_rate", row=2, col=2, value=0.21, number_format=percent_format()) + path = tmp_path / "t.xlsx" + wb.save(path) + back = load_workbook(path) + assert "tax_rate" in back.defined_names + assert back["Assumptions"].cell(row=2, column=2).value == 0.21 + + +def test_add_named_row_registers_range(tmp_path): + wb = Workbook() + ws = wb.active + ws.title = "Assumptions" + add_named_row(wb, ws, name="seasonality_ch1", row=3, start_col=2, + values=[1.0] * 12, number_format=None) + path = tmp_path / "t.xlsx" + wb.save(path) + back = load_workbook(path) + assert "seasonality_ch1" in back.defined_names + assert back["Assumptions"].cell(row=3, column=13).value == 1.0 + + +def test_fill_formula_row_writes_formula_strings(): + wb = Workbook() + ws = wb.active + ws.title = "Model" + # template receives 1-based month index and the column letter for that month + fill_formula_row( + ws, row=5, label="gross_profit", start_col=2, n_cols=3, + template=lambda m, col: f"={col}3-{col}4", + ) + assert ws.cell(row=5, column=1).value == "gross_profit" + assert ws.cell(row=5, column=2).value == "=B3-B4" + assert ws.cell(row=5, column=4).value == "=D3-D4" + + +def test_money_and_percent_formats_are_strings(): + assert isinstance(money_format(), str) + assert isinstance(percent_format(), str) diff --git a/tests/test_excel_verify.py b/tests/test_excel_verify.py new file mode 100644 index 0000000..fe3267c --- /dev/null +++ b/tests/test_excel_verify.py @@ -0,0 +1,77 @@ +import pandas as pd +import pytest + +pytest.importorskip("formulas") + +from pyfpa.config.schemas import EntityConfig +from pyfpa.excel.model_workbook import model_to_excel +from pyfpa.excel.verify import verify_workbook +from pyfpa.models.cashflow import cashflow_from_config + + +def _simple_cfg(): + return EntityConfig.model_validate({ + "name": "T", "start_month": "2026-01", "horizon_months": 6, "tax_rate": 0.0, + "channels": [{"name": "A", "annual_revenue": 120_000.0, "growth_rate": 0.0, + "seasonality": [1.0] * 12, "cogs_pct": 0.5}], + "opex": [], "debt": [], + "working_capital": {"dso_days": 0.0, "dpo_days": 0.0, "dio_days": 0.0}, + "opening_balances": {"cash": 0.0}, + }) + + +def test_verify_passes_on_faithful_workbook(tmp_path): + cfg = _simple_cfg() + path = tmp_path / "m.xlsx" + model_to_excel(cfg, path) + report = verify_workbook(path, cashflow_from_config(cfg)) + assert report.passed, report.failures + + +def test_verify_fails_on_corrupted_formula(tmp_path): + from openpyxl import load_workbook + cfg = _simple_cfg() + path = tmp_path / "m.xlsx" + model_to_excel(cfg, path) + wb = load_workbook(path) + model = wb["Model"] + labels = {model.cell(row=r, column=1).value: r for r in range(2, model.max_row + 1)} + model.cell(row=labels["gross_profit"], column=3, value="=1234567") + wb.save(path) + report = verify_workbook(path, cashflow_from_config(cfg)) + assert not report.passed + assert any("gross_profit" in f for f in report.failures) + + +def test_missing_formulas_dependency_message(): + import pyfpa.excel.verify as v + import builtins + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "formulas": + raise ImportError("nope") + return real_import(name, *args, **kwargs) + + builtins.__import__ = fake_import + try: + with pytest.raises(RuntimeError, match="pip install formulas"): + v._load_formulas() + finally: + builtins.__import__ = real_import + + +def test_verify_fails_cleanly_on_excel_error_cell(tmp_path): + # an errored cell (#DIV/0!) must produce a failing report, never a crash + from openpyxl import load_workbook + cfg = _simple_cfg() + path = tmp_path / "m.xlsx" + model_to_excel(cfg, path) + wb = load_workbook(path) + model = wb["Model"] + labels = {model.cell(row=r, column=1).value: r for r in range(2, model.max_row + 1)} + model.cell(row=labels["gross_profit"], column=3, value="=1/0") + wb.save(path) + report = verify_workbook(path, cashflow_from_config(cfg)) + assert not report.passed + assert any("error" in f.lower() for f in report.failures) diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 189227c..42f6fce 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -19,6 +19,7 @@ def test_all_matches_public_api(): "working_capital_from_config", "debt_from_config", "cashflow_from_config", "Cash13Config", "WeeklyFlow", "expand_flow", "cash13_forecast", "runway_summary", "load_cash13_config", "read_pl_csv", "to_briefing_md", "forecast_to_excel", + "model_to_excel", "verify_workbook", "Sku", "sku_profitability", "pareto_breakpoint", "load_skus", "Segment", "segment_pnl", "roll_up_segments", "segments_to_channels", "Carveout", "divest", "net_debt_to_ebitda", "reconcile",