excel-model 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- excel_model-0.1.0/.gitignore +28 -0
- excel_model-0.1.0/LICENSE +21 -0
- excel_model-0.1.0/PKG-INFO +109 -0
- excel_model-0.1.0/README.md +79 -0
- excel_model-0.1.0/excel_model/__init__.py +1 -0
- excel_model-0.1.0/excel_model/cli.py +261 -0
- excel_model-0.1.0/excel_model/config.py +69 -0
- excel_model-0.1.0/excel_model/defaults/__init__.py +0 -0
- excel_model-0.1.0/excel_model/defaults/default_style.yaml +12 -0
- excel_model-0.1.0/excel_model/excel_writer.py +65 -0
- excel_model-0.1.0/excel_model/exceptions.py +21 -0
- excel_model-0.1.0/excel_model/formula_engine.py +271 -0
- excel_model-0.1.0/excel_model/loader.py +111 -0
- excel_model-0.1.0/excel_model/models/__init__.py +1 -0
- excel_model-0.1.0/excel_model/models/_sheet_builder.py +274 -0
- excel_model-0.1.0/excel_model/models/budget_vs_actuals.py +179 -0
- excel_model-0.1.0/excel_model/models/comparison.py +164 -0
- excel_model-0.1.0/excel_model/models/dcf.py +207 -0
- excel_model-0.1.0/excel_model/models/p_and_l.py +181 -0
- excel_model-0.1.0/excel_model/models/scenario.py +254 -0
- excel_model-0.1.0/excel_model/named_ranges.py +28 -0
- excel_model-0.1.0/excel_model/spec.py +88 -0
- excel_model-0.1.0/excel_model/spec_loader.py +154 -0
- excel_model-0.1.0/excel_model/spec_schema.py +97 -0
- excel_model-0.1.0/excel_model/style.py +219 -0
- excel_model-0.1.0/excel_model/time_engine.py +133 -0
- excel_model-0.1.0/excel_model/validator.py +245 -0
- excel_model-0.1.0/pyproject.toml +59 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
*.egg
|
|
8
|
+
|
|
9
|
+
# pixi
|
|
10
|
+
.pixi/
|
|
11
|
+
|
|
12
|
+
# IDE
|
|
13
|
+
.vscode/
|
|
14
|
+
.idea/
|
|
15
|
+
*.swp
|
|
16
|
+
|
|
17
|
+
# OS
|
|
18
|
+
.DS_Store
|
|
19
|
+
Thumbs.db
|
|
20
|
+
|
|
21
|
+
# Testing
|
|
22
|
+
.pytest_cache/
|
|
23
|
+
.hypothesis/
|
|
24
|
+
.coverage
|
|
25
|
+
htmlcov/
|
|
26
|
+
|
|
27
|
+
# Docs
|
|
28
|
+
site/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthias Christenson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: excel-model
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: YAML-driven Excel financial model generator
|
|
5
|
+
Project-URL: Documentation, https://neuralsignal.github.io/excel-model/
|
|
6
|
+
Project-URL: Repository, https://github.com/neuralsignal/excel-model
|
|
7
|
+
Author: Matthias Christenson
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: dcf,excel,financial-model,openpyxl,p&l,scenario,yaml
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Requires-Dist: click<9,>=8.0
|
|
20
|
+
Requires-Dist: dacite<2,>=1.8
|
|
21
|
+
Requires-Dist: openpyxl<4,>=3.1
|
|
22
|
+
Requires-Dist: polars<2,>=1.0
|
|
23
|
+
Requires-Dist: pyyaml<7,>=6.0
|
|
24
|
+
Requires-Dist: strictyaml<2,>=1.3
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: hypothesis<7,>=6.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-cov<6,>=5.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest<9,>=8.0; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# excel-model
|
|
32
|
+
|
|
33
|
+
YAML-driven Excel financial model generator.
|
|
34
|
+
|
|
35
|
+
Build professional financial models (P&L, DCF, Budget vs Actuals, Scenario Analysis) from declarative YAML specs. Generates `.xlsx` workbooks with named ranges, styled sheets, and Excel formulas using openpyxl.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install excel-model
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or for development:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pixi install
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quick Start
|
|
50
|
+
|
|
51
|
+
### CLI
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Build a P&L model
|
|
55
|
+
excel-model build --spec model.yaml --output model.xlsx --mode batch
|
|
56
|
+
|
|
57
|
+
# Validate a spec
|
|
58
|
+
excel-model validate --spec model.yaml
|
|
59
|
+
|
|
60
|
+
# Describe what a spec would produce
|
|
61
|
+
excel-model describe --spec model.yaml --format text
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Python API
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from excel_model.spec_loader import load_spec
|
|
68
|
+
from excel_model.validator import validate_spec
|
|
69
|
+
from excel_model.excel_writer import build_workbook
|
|
70
|
+
from excel_model.config import load_style
|
|
71
|
+
|
|
72
|
+
spec = load_spec("model.yaml")
|
|
73
|
+
errors = validate_spec(spec)
|
|
74
|
+
assert not errors
|
|
75
|
+
|
|
76
|
+
style = load_style(None) # uses bundled defaults
|
|
77
|
+
build_workbook(spec=spec, inputs=None, output_path="model.xlsx", style=style)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Model Types
|
|
81
|
+
|
|
82
|
+
| Type | Description |
|
|
83
|
+
|------|-------------|
|
|
84
|
+
| `p_and_l` | Profit & Loss statement |
|
|
85
|
+
| `dcf` | Discounted Cash Flow valuation |
|
|
86
|
+
| `budget_vs_actuals` | Budget vs Actuals comparison |
|
|
87
|
+
| `scenario` | Multi-scenario analysis (Base/Bull/Bear) |
|
|
88
|
+
| `comparison` | Cross-entity comparison |
|
|
89
|
+
|
|
90
|
+
## Formula Types
|
|
91
|
+
|
|
92
|
+
21 built-in formula types including `growth_projected`, `pct_of_revenue`, `sum_of_rows`, `subtraction`, `ratio`, `discounted_pv`, `terminal_value`, `npv_sum`, `variance`, `variance_pct`, `constant`, `custom`, and more.
|
|
93
|
+
|
|
94
|
+
## Configuration
|
|
95
|
+
|
|
96
|
+
Style config controls Excel formatting (colors, fonts, number formats). A bundled default is included; override with `--style`:
|
|
97
|
+
|
|
98
|
+
```yaml
|
|
99
|
+
header_fill_hex: "1F3864"
|
|
100
|
+
header_font_color: "FFFFFF"
|
|
101
|
+
font_name: "Calibri"
|
|
102
|
+
font_size: 10
|
|
103
|
+
number_format_currency: '#,##0'
|
|
104
|
+
number_format_percent: '0.0%'
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# excel-model
|
|
2
|
+
|
|
3
|
+
YAML-driven Excel financial model generator.
|
|
4
|
+
|
|
5
|
+
Build professional financial models (P&L, DCF, Budget vs Actuals, Scenario Analysis) from declarative YAML specs. Generates `.xlsx` workbooks with named ranges, styled sheets, and Excel formulas using openpyxl.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install excel-model
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or for development:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pixi install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
### CLI
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Build a P&L model
|
|
25
|
+
excel-model build --spec model.yaml --output model.xlsx --mode batch
|
|
26
|
+
|
|
27
|
+
# Validate a spec
|
|
28
|
+
excel-model validate --spec model.yaml
|
|
29
|
+
|
|
30
|
+
# Describe what a spec would produce
|
|
31
|
+
excel-model describe --spec model.yaml --format text
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Python API
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from excel_model.spec_loader import load_spec
|
|
38
|
+
from excel_model.validator import validate_spec
|
|
39
|
+
from excel_model.excel_writer import build_workbook
|
|
40
|
+
from excel_model.config import load_style
|
|
41
|
+
|
|
42
|
+
spec = load_spec("model.yaml")
|
|
43
|
+
errors = validate_spec(spec)
|
|
44
|
+
assert not errors
|
|
45
|
+
|
|
46
|
+
style = load_style(None) # uses bundled defaults
|
|
47
|
+
build_workbook(spec=spec, inputs=None, output_path="model.xlsx", style=style)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Model Types
|
|
51
|
+
|
|
52
|
+
| Type | Description |
|
|
53
|
+
|------|-------------|
|
|
54
|
+
| `p_and_l` | Profit & Loss statement |
|
|
55
|
+
| `dcf` | Discounted Cash Flow valuation |
|
|
56
|
+
| `budget_vs_actuals` | Budget vs Actuals comparison |
|
|
57
|
+
| `scenario` | Multi-scenario analysis (Base/Bull/Bear) |
|
|
58
|
+
| `comparison` | Cross-entity comparison |
|
|
59
|
+
|
|
60
|
+
## Formula Types
|
|
61
|
+
|
|
62
|
+
21 built-in formula types including `growth_projected`, `pct_of_revenue`, `sum_of_rows`, `subtraction`, `ratio`, `discounted_pv`, `terminal_value`, `npv_sum`, `variance`, `variance_pct`, `constant`, `custom`, and more.
|
|
63
|
+
|
|
64
|
+
## Configuration
|
|
65
|
+
|
|
66
|
+
Style config controls Excel formatting (colors, fonts, number formats). A bundled default is included; override with `--style`:
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
header_fill_hex: "1F3864"
|
|
70
|
+
header_font_color: "FFFFFF"
|
|
71
|
+
font_name: "Calibri"
|
|
72
|
+
font_size: 10
|
|
73
|
+
number_format_currency: '#,##0'
|
|
74
|
+
number_format_percent: '0.0%'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""excel_model — Excel financial model builder."""
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""CLI entry point for excel-model."""
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from excel_model.config import load_style
|
|
9
|
+
from excel_model.exceptions import StyleConfigError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group()
|
|
13
|
+
def main() -> None:
|
|
14
|
+
"""YAML-driven Excel financial model generator."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@main.command()
|
|
18
|
+
@click.option("--spec", required=True, type=click.Path(exists=True), help="Path to model spec YAML")
|
|
19
|
+
@click.option("--output", required=True, type=click.Path(), help="Path for output .xlsx file")
|
|
20
|
+
@click.option("--style", required=False, type=click.Path(exists=True), help="Path to style config YAML (uses bundled defaults if omitted)")
|
|
21
|
+
@click.option("--data", required=False, type=click.Path(exists=True), help="Path to input data file")
|
|
22
|
+
@click.option("--mode", required=True, type=click.Choice(["batch", "interactive"]), help="batch = JSON to stdout; interactive = verbose narrative")
|
|
23
|
+
def build(spec: str, output: str, style: str | None, data: str | None, mode: str) -> None:
|
|
24
|
+
"""Build an Excel financial model from a YAML spec."""
|
|
25
|
+
def emit_error(message: str) -> None:
|
|
26
|
+
if mode == "batch":
|
|
27
|
+
click.echo(json.dumps({"status": "error", "message": message}))
|
|
28
|
+
else:
|
|
29
|
+
click.echo(f"ERROR: {message}", err=True)
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
|
|
32
|
+
def emit_info(message: str) -> None:
|
|
33
|
+
if mode == "interactive":
|
|
34
|
+
click.echo(message)
|
|
35
|
+
|
|
36
|
+
# Load spec
|
|
37
|
+
emit_info(f"Loading model spec: {spec}")
|
|
38
|
+
try:
|
|
39
|
+
from excel_model.spec_loader import load_spec
|
|
40
|
+
loaded_spec = load_spec(spec)
|
|
41
|
+
except (FileNotFoundError, ValueError, KeyError) as e:
|
|
42
|
+
emit_error(f"Failed to load spec: {e}")
|
|
43
|
+
return # unreachable, but keeps type checker happy
|
|
44
|
+
|
|
45
|
+
# Validate spec
|
|
46
|
+
emit_info("Validating model spec...")
|
|
47
|
+
from excel_model.validator import validate_spec
|
|
48
|
+
errors = validate_spec(loaded_spec)
|
|
49
|
+
if errors:
|
|
50
|
+
emit_error("Spec validation failed:\n" + "\n".join(f" - {e}" for e in errors))
|
|
51
|
+
emit_info(f" Model type: {loaded_spec.model_type}")
|
|
52
|
+
emit_info(f" Title: {loaded_spec.title}")
|
|
53
|
+
emit_info(f" Currency: {loaded_spec.currency}")
|
|
54
|
+
emit_info(f" Periods: {loaded_spec.n_history_periods} history + {loaded_spec.n_periods} projection ({loaded_spec.granularity})")
|
|
55
|
+
emit_info(f" Assumptions: {len(loaded_spec.assumptions)}")
|
|
56
|
+
emit_info(f" Line items: {len(loaded_spec.line_items)}")
|
|
57
|
+
|
|
58
|
+
# Load style
|
|
59
|
+
emit_info(f"Loading style config: {style or '(bundled defaults)'}")
|
|
60
|
+
try:
|
|
61
|
+
loaded_style = load_style(style)
|
|
62
|
+
except StyleConfigError as e:
|
|
63
|
+
emit_error(f"Failed to load style config: {e}")
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
# Load input data (optional)
|
|
67
|
+
inputs = None
|
|
68
|
+
if data:
|
|
69
|
+
emit_info(f"Loading input data: {data}")
|
|
70
|
+
try:
|
|
71
|
+
from excel_model.loader import load
|
|
72
|
+
value_cols = list(loaded_spec.inputs.value_cols.values())
|
|
73
|
+
inputs = load(
|
|
74
|
+
source_path=data,
|
|
75
|
+
period_col=loaded_spec.inputs.period_col,
|
|
76
|
+
value_cols=value_cols,
|
|
77
|
+
sheet=loaded_spec.inputs.sheet,
|
|
78
|
+
)
|
|
79
|
+
emit_info(f" Loaded {len(inputs.df)} rows")
|
|
80
|
+
|
|
81
|
+
from excel_model.validator import validate_inputs_against_spec
|
|
82
|
+
input_errors = validate_inputs_against_spec(loaded_spec, inputs)
|
|
83
|
+
if input_errors:
|
|
84
|
+
emit_error("Input data validation failed:\n" + "\n".join(f" - {e}" for e in input_errors))
|
|
85
|
+
except (FileNotFoundError, ValueError) as e:
|
|
86
|
+
emit_error(f"Failed to load input data: {e}")
|
|
87
|
+
|
|
88
|
+
# Build workbook
|
|
89
|
+
emit_info("Building workbook...")
|
|
90
|
+
try:
|
|
91
|
+
from excel_model.excel_writer import build_workbook
|
|
92
|
+
build_workbook(spec=loaded_spec, inputs=inputs, output_path=output, style=loaded_style)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
emit_error(f"Failed to build workbook: {e}")
|
|
95
|
+
|
|
96
|
+
output_path = str(Path(output).resolve())
|
|
97
|
+
emit_info(f"Workbook saved to: {output_path}")
|
|
98
|
+
|
|
99
|
+
if mode == "batch":
|
|
100
|
+
click.echo(json.dumps({"status": "ok", "output": output_path}))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@main.command()
|
|
104
|
+
@click.option("--spec", required=True, type=click.Path(exists=True), help="Path to model spec YAML")
|
|
105
|
+
@click.option("--data", required=False, type=click.Path(exists=True), help="Optional input data file to validate column mapping")
|
|
106
|
+
def validate(spec: str, data: str | None) -> None:
|
|
107
|
+
"""Validate a model spec YAML file."""
|
|
108
|
+
# Load spec
|
|
109
|
+
try:
|
|
110
|
+
from excel_model.spec_loader import load_spec
|
|
111
|
+
loaded_spec = load_spec(spec)
|
|
112
|
+
except (FileNotFoundError, ValueError, KeyError) as e:
|
|
113
|
+
click.echo(f"ERROR: {e}")
|
|
114
|
+
sys.exit(1)
|
|
115
|
+
|
|
116
|
+
# Validate spec
|
|
117
|
+
from excel_model.validator import validate_spec
|
|
118
|
+
errors = validate_spec(loaded_spec)
|
|
119
|
+
|
|
120
|
+
# Optionally validate input data columns
|
|
121
|
+
if data:
|
|
122
|
+
try:
|
|
123
|
+
from excel_model.loader import load
|
|
124
|
+
value_cols = list(loaded_spec.inputs.value_cols.values())
|
|
125
|
+
inputs = load(
|
|
126
|
+
source_path=data,
|
|
127
|
+
period_col=loaded_spec.inputs.period_col,
|
|
128
|
+
value_cols=value_cols,
|
|
129
|
+
sheet=loaded_spec.inputs.sheet,
|
|
130
|
+
)
|
|
131
|
+
from excel_model.validator import validate_inputs_against_spec
|
|
132
|
+
input_errors = validate_inputs_against_spec(loaded_spec, inputs)
|
|
133
|
+
errors.extend(input_errors)
|
|
134
|
+
except (FileNotFoundError, ValueError) as e:
|
|
135
|
+
errors.append(f"Input data: {e}")
|
|
136
|
+
|
|
137
|
+
if errors:
|
|
138
|
+
for err in errors:
|
|
139
|
+
click.echo(err)
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
else:
|
|
142
|
+
click.echo("OK")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@main.command()
|
|
146
|
+
@click.option("--spec", required=True, type=click.Path(exists=True), help="Path to model spec YAML")
|
|
147
|
+
@click.option("--format", "output_format", required=True, type=click.Choice(["text", "json"]), help="Output format")
|
|
148
|
+
def describe(spec: str, output_format: str) -> None:
|
|
149
|
+
"""Dry-run description of what build would produce."""
|
|
150
|
+
# Load spec
|
|
151
|
+
try:
|
|
152
|
+
from excel_model.spec_loader import load_spec
|
|
153
|
+
loaded_spec = load_spec(spec)
|
|
154
|
+
except (FileNotFoundError, ValueError, KeyError) as e:
|
|
155
|
+
click.echo(f"ERROR: Failed to load spec: {e}", err=True)
|
|
156
|
+
sys.exit(1)
|
|
157
|
+
|
|
158
|
+
# Validate spec
|
|
159
|
+
from excel_model.validator import validate_spec
|
|
160
|
+
errors = validate_spec(loaded_spec)
|
|
161
|
+
|
|
162
|
+
# Build description
|
|
163
|
+
from excel_model.time_engine import generate_periods
|
|
164
|
+
try:
|
|
165
|
+
periods = generate_periods(
|
|
166
|
+
start_period=loaded_spec.start_period,
|
|
167
|
+
n_periods=loaded_spec.n_periods,
|
|
168
|
+
n_history=loaded_spec.n_history_periods,
|
|
169
|
+
granularity=loaded_spec.granularity,
|
|
170
|
+
)
|
|
171
|
+
except ValueError:
|
|
172
|
+
periods = []
|
|
173
|
+
|
|
174
|
+
# Group assumptions by group
|
|
175
|
+
assumption_groups: dict[str, list] = {}
|
|
176
|
+
for a in loaded_spec.assumptions:
|
|
177
|
+
assumption_groups.setdefault(a.group, []).append(a)
|
|
178
|
+
|
|
179
|
+
# Group line items by section
|
|
180
|
+
sections: dict[str, list] = {}
|
|
181
|
+
for li in loaded_spec.line_items:
|
|
182
|
+
sections.setdefault(li.section, []).append(li)
|
|
183
|
+
|
|
184
|
+
description = {
|
|
185
|
+
"model_type": loaded_spec.model_type,
|
|
186
|
+
"title": loaded_spec.title,
|
|
187
|
+
"currency": loaded_spec.currency,
|
|
188
|
+
"granularity": loaded_spec.granularity,
|
|
189
|
+
"start_period": loaded_spec.start_period,
|
|
190
|
+
"n_history_periods": loaded_spec.n_history_periods,
|
|
191
|
+
"n_periods": loaded_spec.n_periods,
|
|
192
|
+
"total_periods": len(periods),
|
|
193
|
+
"period_labels": [p.label for p in periods],
|
|
194
|
+
"metadata": {
|
|
195
|
+
"preparer": loaded_spec.metadata.preparer,
|
|
196
|
+
"date": loaded_spec.metadata.date,
|
|
197
|
+
"version": loaded_spec.metadata.version,
|
|
198
|
+
},
|
|
199
|
+
"assumptions_count": len(loaded_spec.assumptions),
|
|
200
|
+
"assumption_groups": {
|
|
201
|
+
group: [{"name": a.name, "value": a.value, "format": a.format} for a in assumptions]
|
|
202
|
+
for group, assumptions in assumption_groups.items()
|
|
203
|
+
},
|
|
204
|
+
"line_items_count": len(loaded_spec.line_items),
|
|
205
|
+
"sections": {
|
|
206
|
+
section: [{"key": li.key, "label": li.label, "formula_type": li.formula_type} for li in items]
|
|
207
|
+
for section, items in sections.items()
|
|
208
|
+
},
|
|
209
|
+
"scenarios": [{"name": s.name, "label": s.label} for s in loaded_spec.scenarios],
|
|
210
|
+
"column_groups": [{"key": cg.key, "label": cg.label} for cg in loaded_spec.column_groups],
|
|
211
|
+
"sheets_to_create": ["Assumptions", "Inputs", "Model"],
|
|
212
|
+
"validation_errors": errors,
|
|
213
|
+
"inputs": {
|
|
214
|
+
"source": loaded_spec.inputs.source,
|
|
215
|
+
"period_col": loaded_spec.inputs.period_col,
|
|
216
|
+
"value_cols": dict(loaded_spec.inputs.value_cols),
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if output_format == "json":
|
|
221
|
+
click.echo(json.dumps(description, indent=2))
|
|
222
|
+
else:
|
|
223
|
+
click.echo(f"Model: {loaded_spec.title}")
|
|
224
|
+
click.echo(f"Type: {loaded_spec.model_type}")
|
|
225
|
+
click.echo(f"Currency: {loaded_spec.currency}")
|
|
226
|
+
click.echo()
|
|
227
|
+
click.echo(f"Periods ({loaded_spec.granularity}):")
|
|
228
|
+
if loaded_spec.n_history_periods > 0:
|
|
229
|
+
hist = [p.label for p in periods if p.is_history]
|
|
230
|
+
click.echo(f" History ({loaded_spec.n_history_periods}): {', '.join(hist)}")
|
|
231
|
+
proj = [p.label for p in periods if not p.is_history]
|
|
232
|
+
click.echo(f" Projection ({loaded_spec.n_periods}): {', '.join(proj)}")
|
|
233
|
+
click.echo()
|
|
234
|
+
click.echo(f"Assumptions ({len(loaded_spec.assumptions)}):")
|
|
235
|
+
for group, assumptions in assumption_groups.items():
|
|
236
|
+
click.echo(f" [{group}]")
|
|
237
|
+
for a in assumptions:
|
|
238
|
+
click.echo(f" {a.name}: {a.value} ({a.format})")
|
|
239
|
+
click.echo()
|
|
240
|
+
click.echo(f"Line Items ({len(loaded_spec.line_items)}):")
|
|
241
|
+
for section, items in sections.items():
|
|
242
|
+
if section:
|
|
243
|
+
click.echo(f" [{section}]")
|
|
244
|
+
for li in items:
|
|
245
|
+
marker = " [subtotal]" if li.is_subtotal else (" [total]" if li.is_total else "")
|
|
246
|
+
click.echo(f" {li.label.strip()}: {li.formula_type}{marker}")
|
|
247
|
+
if loaded_spec.scenarios:
|
|
248
|
+
click.echo()
|
|
249
|
+
click.echo(f"Scenarios ({len(loaded_spec.scenarios)}):")
|
|
250
|
+
for s in loaded_spec.scenarios:
|
|
251
|
+
overrides = ", ".join(f"{k}={v}" for k, v in s.assumption_overrides.items())
|
|
252
|
+
override_str = f" (overrides: {overrides})" if overrides else ""
|
|
253
|
+
click.echo(f" {s.label}{override_str}")
|
|
254
|
+
if errors:
|
|
255
|
+
click.echo()
|
|
256
|
+
click.echo(f"Validation errors ({len(errors)}):")
|
|
257
|
+
for e in errors:
|
|
258
|
+
click.echo(f" - {e}")
|
|
259
|
+
else:
|
|
260
|
+
click.echo()
|
|
261
|
+
click.echo("Validation: OK")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Configuration loading with bundled defaults and deep-merge."""
|
|
2
|
+
from importlib import resources
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from excel_model.exceptions import StyleConfigError
|
|
8
|
+
from excel_model.style import StyleConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load_default_style_yaml() -> dict:
|
|
12
|
+
"""Load the bundled default style config."""
|
|
13
|
+
ref = resources.files("excel_model") / "defaults" / "default_style.yaml"
|
|
14
|
+
return yaml.safe_load(ref.read_text(encoding="utf-8"))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
18
|
+
"""Recursively merge override into base. Override values win."""
|
|
19
|
+
result = dict(base)
|
|
20
|
+
for key, value in override.items():
|
|
21
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
22
|
+
result[key] = _deep_merge(result[key], value)
|
|
23
|
+
else:
|
|
24
|
+
result[key] = value
|
|
25
|
+
return result
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_style(style_path: str | None) -> StyleConfig:
|
|
29
|
+
"""Load StyleConfig from YAML, deep-merged with bundled defaults.
|
|
30
|
+
|
|
31
|
+
If style_path is None, returns the bundled defaults only.
|
|
32
|
+
If style_path is provided, user overrides are merged on top of defaults.
|
|
33
|
+
"""
|
|
34
|
+
defaults = _load_default_style_yaml()
|
|
35
|
+
|
|
36
|
+
if style_path is not None:
|
|
37
|
+
p = Path(style_path)
|
|
38
|
+
if not p.exists():
|
|
39
|
+
raise StyleConfigError(f"Style config not found: {style_path}")
|
|
40
|
+
with p.open() as f:
|
|
41
|
+
user_data = yaml.safe_load(f) or {}
|
|
42
|
+
merged = _deep_merge(defaults, user_data)
|
|
43
|
+
else:
|
|
44
|
+
merged = defaults
|
|
45
|
+
|
|
46
|
+
required = [
|
|
47
|
+
"header_fill_hex", "header_font_color", "subtotal_fill_hex", "total_fill_hex",
|
|
48
|
+
"history_col_fill_hex", "section_header_fill_hex", "font_name", "font_size",
|
|
49
|
+
"number_format_currency", "number_format_percent",
|
|
50
|
+
"number_format_integer", "number_format_number",
|
|
51
|
+
]
|
|
52
|
+
missing = [k for k in required if k not in merged]
|
|
53
|
+
if missing:
|
|
54
|
+
raise StyleConfigError(f"Style config missing required keys: {missing}")
|
|
55
|
+
|
|
56
|
+
return StyleConfig(
|
|
57
|
+
header_fill_hex=merged["header_fill_hex"],
|
|
58
|
+
header_font_color=merged["header_font_color"],
|
|
59
|
+
subtotal_fill_hex=merged["subtotal_fill_hex"],
|
|
60
|
+
total_fill_hex=merged["total_fill_hex"],
|
|
61
|
+
history_col_fill_hex=merged["history_col_fill_hex"],
|
|
62
|
+
section_header_fill_hex=merged["section_header_fill_hex"],
|
|
63
|
+
font_name=merged["font_name"],
|
|
64
|
+
font_size=int(merged["font_size"]),
|
|
65
|
+
number_format_currency=merged["number_format_currency"],
|
|
66
|
+
number_format_percent=merged["number_format_percent"],
|
|
67
|
+
number_format_integer=merged["number_format_integer"],
|
|
68
|
+
number_format_number=merged["number_format_number"],
|
|
69
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
header_fill_hex: "1F3864"
|
|
2
|
+
header_font_color: "FFFFFF"
|
|
3
|
+
subtotal_fill_hex: "D6E4F0"
|
|
4
|
+
total_fill_hex: "AED6F1"
|
|
5
|
+
history_col_fill_hex: "F2F2F2"
|
|
6
|
+
section_header_fill_hex: "E8F4FD"
|
|
7
|
+
font_name: "Calibri"
|
|
8
|
+
font_size: 10
|
|
9
|
+
number_format_currency: '#,##0'
|
|
10
|
+
number_format_percent: '0.0%'
|
|
11
|
+
number_format_integer: '#,##0'
|
|
12
|
+
number_format_number: '#,##0.00'
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Orchestrator: build_workbook() — creates the full Excel workbook."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from openpyxl import Workbook
|
|
5
|
+
|
|
6
|
+
from excel_model.loader import InputData
|
|
7
|
+
from excel_model.spec import ModelSpec
|
|
8
|
+
from excel_model.style import StyleConfig
|
|
9
|
+
from excel_model.time_engine import generate_periods
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_workbook(
|
|
13
|
+
spec: ModelSpec,
|
|
14
|
+
inputs: InputData | None,
|
|
15
|
+
output_path: str,
|
|
16
|
+
style: StyleConfig,
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Build and save a complete Excel workbook from the model spec.
|
|
19
|
+
|
|
20
|
+
Creates Assumptions, Inputs, and Model sheets.
|
|
21
|
+
Dispatches to the appropriate model builder based on spec.model_type.
|
|
22
|
+
"""
|
|
23
|
+
wb = Workbook()
|
|
24
|
+
# Remove default sheet
|
|
25
|
+
if "Sheet" in wb.sheetnames:
|
|
26
|
+
del wb["Sheet"]
|
|
27
|
+
|
|
28
|
+
if spec.model_type == "comparison":
|
|
29
|
+
# Comparison models don't use time_engine periods
|
|
30
|
+
from excel_model.models.comparison import build_comparison
|
|
31
|
+
build_comparison(wb, spec, inputs, style)
|
|
32
|
+
else:
|
|
33
|
+
periods = generate_periods(
|
|
34
|
+
start_period=spec.start_period,
|
|
35
|
+
n_periods=spec.n_periods,
|
|
36
|
+
n_history=spec.n_history_periods,
|
|
37
|
+
granularity=spec.granularity,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if spec.model_type == "p_and_l":
|
|
41
|
+
from excel_model.models.p_and_l import build_p_and_l
|
|
42
|
+
build_p_and_l(wb, spec, inputs, style, periods)
|
|
43
|
+
|
|
44
|
+
elif spec.model_type == "dcf":
|
|
45
|
+
from excel_model.models.dcf import build_dcf
|
|
46
|
+
build_dcf(wb, spec, inputs, style, periods)
|
|
47
|
+
|
|
48
|
+
elif spec.model_type == "budget_vs_actuals":
|
|
49
|
+
from excel_model.models.budget_vs_actuals import build_budget_vs_actuals
|
|
50
|
+
build_budget_vs_actuals(wb, spec, inputs, style, periods)
|
|
51
|
+
|
|
52
|
+
elif spec.model_type == "scenario":
|
|
53
|
+
from excel_model.models.scenario import build_scenario
|
|
54
|
+
build_scenario(wb, spec, inputs, style, periods)
|
|
55
|
+
|
|
56
|
+
elif spec.model_type == "custom":
|
|
57
|
+
from excel_model.models.p_and_l import build_p_and_l
|
|
58
|
+
build_p_and_l(wb, spec, inputs, style, periods)
|
|
59
|
+
|
|
60
|
+
else:
|
|
61
|
+
raise ValueError(f"Unknown model_type: {spec.model_type!r}")
|
|
62
|
+
|
|
63
|
+
out = Path(output_path)
|
|
64
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
wb.save(str(out))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Custom exception classes for excel-model."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ExcelModelError(Exception):
|
|
5
|
+
"""Base exception for all excel-model errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SpecValidationError(ExcelModelError):
|
|
9
|
+
"""Raised when a model spec fails validation."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FormulaError(ExcelModelError):
|
|
13
|
+
"""Raised when a formula cannot be rendered."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InputDataError(ExcelModelError):
|
|
17
|
+
"""Raised when input data is missing, malformed, or incompatible."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StyleConfigError(ExcelModelError):
|
|
21
|
+
"""Raised when style configuration is invalid or missing."""
|