pyproforma 0.2.4__tar.gz → 0.2.5__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.
- pyproforma-0.2.5/PKG-INFO +301 -0
- pyproforma-0.2.5/README.md +260 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/explorer/app.py +61 -34
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/explorer/templates/base.html +4 -3
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/explorer/templates/table_view.html +10 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/explorer/templates/view.html +13 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/proforma_model.py +22 -11
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/reserved_words.py +0 -1
- pyproforma-0.2.5/pyproforma/results/line_item_stat.py +133 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/table/excel.py +50 -54
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/table/table_class.py +5 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/tables/row_types.py +3 -0
- pyproforma-0.2.5/pyproforma.egg-info/PKG-INFO +301 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma.egg-info/SOURCES.txt +0 -1
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproject.toml +1 -1
- pyproforma-0.2.4/PKG-INFO +0 -244
- pyproforma-0.2.4/README.md +0 -203
- pyproforma-0.2.4/pyproforma/explorer/templates/inputs.html +0 -107
- pyproforma-0.2.4/pyproforma/results/line_item_stat.py +0 -120
- pyproforma-0.2.4/pyproforma.egg-info/PKG-INFO +0 -244
- {pyproforma-0.2.4 → pyproforma-0.2.5}/LICENSE +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/MANIFEST.in +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/__init__.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/chart/__init__.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/chart/chart.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/chart/renderers/__init__.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/chart/renderers/base.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/chart/renderers/matplotlib_renderer.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/charts/__init__.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/charts/chart_def.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/charts/charts.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/compare/__init__.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/compare/model_comparison.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/engine/__init__.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/engine/calculation_engine.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/engine/line_item_values.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/engine/model_namespace.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/explorer/__init__.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/explorer/components.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/explorer/templates/chart_view.html +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/explorer/templates/index.html +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/explorer/templates/line_item.html +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/results/__init__.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/results/line_item_result.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/results/line_item_selection.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/results/scalar_result.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/results/tags_namespace.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/specs/__init__.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/specs/debt_line.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/specs/fixed_line.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/specs/formula_line.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/specs/input_line.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/specs/line_item.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/specs/scalar_input_line.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/specs/scalar_line.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/table/__init__.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/table/bootstrap_html_renderer.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/table/colors.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/table/format_value.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/table/html_renderer.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/tables/__init__.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/tables/table_def.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/tables/tables.py +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma.egg-info/dependency_links.txt +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma.egg-info/requires.txt +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma.egg-info/top_level.txt +0 -0
- {pyproforma-0.2.4 → pyproforma-0.2.5}/setup.cfg +0 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyproforma
|
|
3
|
+
Version: 0.2.5
|
|
4
|
+
Summary: A Python package for financial modeling and reporting
|
|
5
|
+
Author-email: Robert Hannay <rhannay@gmail.com>
|
|
6
|
+
Maintainer-email: Robert Hannay <rhannay@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/rhannay/pyproforma
|
|
9
|
+
Project-URL: Repository, https://github.com/rhannay/pyproforma
|
|
10
|
+
Project-URL: Documentation, https://pyproforma.readthedocs.io/en/latest/
|
|
11
|
+
Project-URL: Issues, https://github.com/rhannay/pyproforma/issues
|
|
12
|
+
Keywords: finance,modeling,reporting,excel,proforma
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
15
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Operating System :: OS Independent
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Provides-Extra: pandas
|
|
26
|
+
Requires-Dist: pandas>=1.3.0; extra == "pandas"
|
|
27
|
+
Provides-Extra: excel
|
|
28
|
+
Requires-Dist: openpyxl>=3.0.0; extra == "excel"
|
|
29
|
+
Provides-Extra: charts
|
|
30
|
+
Requires-Dist: matplotlib>=3.5.0; extra == "charts"
|
|
31
|
+
Provides-Extra: notebook
|
|
32
|
+
Requires-Dist: ipython>=7.0.0; extra == "notebook"
|
|
33
|
+
Provides-Extra: explorer
|
|
34
|
+
Requires-Dist: flask>=2.0.0; extra == "explorer"
|
|
35
|
+
Provides-Extra: dev
|
|
36
|
+
Requires-Dist: pytest>=6.0; extra == "dev"
|
|
37
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
38
|
+
Requires-Dist: pandas>=1.3.0; extra == "dev"
|
|
39
|
+
Requires-Dist: openpyxl>=3.0.0; extra == "dev"
|
|
40
|
+
Dynamic: license-file
|
|
41
|
+
|
|
42
|
+
# pyproforma
|
|
43
|
+
|
|
44
|
+
A Python library for building financial models — a code-first alternative to Excel for pro formas, projections, and structured financial tables.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install pyproforma
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Why
|
|
53
|
+
|
|
54
|
+
Spreadsheets are the default tool for financial modeling, but they have real problems: no version control, no testing, formulas hidden inside cells, and no easy way to generate the same model for multiple scenarios. pyproforma is designed for analysts who want the benefits of code — reproducibility, testability, version history — without giving up the tabular output that finance people actually use.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## How it works
|
|
59
|
+
|
|
60
|
+
Define a model by subclassing `ProformaModel` and declaring line items as class attributes. Instantiate it with a list of periods and the library calculates everything.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from pyproforma import ProformaModel, FixedLine, FormulaLine, ScalarLine, Format
|
|
64
|
+
|
|
65
|
+
class IncomeStatement(ProformaModel):
|
|
66
|
+
default_periods = [2024, 2025, 2026]
|
|
67
|
+
|
|
68
|
+
tax_rate = ScalarLine(value=0.21, label="Tax Rate")
|
|
69
|
+
|
|
70
|
+
revenue = FixedLine(
|
|
71
|
+
values={2024: 500_000, 2025: 550_000, 2026: 605_000},
|
|
72
|
+
label="Revenue",
|
|
73
|
+
tags=["operating"],
|
|
74
|
+
value_format=Format.CURRENCY_NO_DECIMALS,
|
|
75
|
+
)
|
|
76
|
+
cogs = FormulaLine(
|
|
77
|
+
formula=lambda li, t: li.revenue[t] * 0.55,
|
|
78
|
+
label="Cost of Goods Sold",
|
|
79
|
+
value_format=Format.CURRENCY_NO_DECIMALS,
|
|
80
|
+
)
|
|
81
|
+
gross_profit = FormulaLine(
|
|
82
|
+
formula=lambda li, t: li.revenue[t] - li.cogs[t],
|
|
83
|
+
label="Gross Profit",
|
|
84
|
+
value_format=Format.CURRENCY_NO_DECIMALS,
|
|
85
|
+
)
|
|
86
|
+
net_income = FormulaLine(
|
|
87
|
+
formula=lambda li, t: li.gross_profit[t] * (1 - li.tax_rate),
|
|
88
|
+
label="Net Income",
|
|
89
|
+
value_format=Format.CURRENCY_NO_DECIMALS,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
model = IncomeStatement() # uses default_periods
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Access results with dot notation (primary) or bracket notation (useful when the name is in a variable):
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
model.net_income[2025] # 173_745.0 — dot notation
|
|
99
|
+
model["net_income"][2025] # same value — bracket notation
|
|
100
|
+
|
|
101
|
+
model.tax_rate.value # 0.21 — scalars have .value, not [t]
|
|
102
|
+
|
|
103
|
+
model.periods # [2024, 2025, 2026]
|
|
104
|
+
model.line_item_names # ["revenue", "cogs", "gross_profit", "net_income"]
|
|
105
|
+
model.scalar_names # ["tax_rate"]
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Line item types
|
|
111
|
+
|
|
112
|
+
| Type | Use |
|
|
113
|
+
|------|-----|
|
|
114
|
+
| `FixedLine(values, ...)` | Hardcoded values per period |
|
|
115
|
+
| `FormulaLine(formula, ...)` | Calculated from other items via a lambda |
|
|
116
|
+
| `ScalarLine(value, ...)` | A single value shared across all periods |
|
|
117
|
+
| `InputLine(default, ...)` | Period-indexed values supplied at instantiation |
|
|
118
|
+
| `ScalarInputLine(default, ...)` | A single value supplied at instantiation |
|
|
119
|
+
|
|
120
|
+
Formula lambdas receive `li` (the model namespace) and `t` (the current period). Period-indexed items use `li.name[t]`; scalars use `li.name` (no `[t]`). Reference prior periods with `li.name[t-1]`.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Scenario inputs
|
|
125
|
+
|
|
126
|
+
`InputLine` and `ScalarInputLine` let callers supply values at instantiation without subclassing again — useful for scenario analysis.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from pyproforma import InputLine, ScalarInputLine
|
|
130
|
+
|
|
131
|
+
class FlexModel(ProformaModel):
|
|
132
|
+
default_periods = [2024, 2025, 2026]
|
|
133
|
+
|
|
134
|
+
margin = ScalarInputLine(default=0.45, label="Gross Margin")
|
|
135
|
+
revenue = FixedLine(
|
|
136
|
+
values={2024: 500_000, 2025: 550_000, 2026: 605_000},
|
|
137
|
+
label="Revenue",
|
|
138
|
+
value_format=Format.CURRENCY_NO_DECIMALS,
|
|
139
|
+
)
|
|
140
|
+
gross_profit = FormulaLine(
|
|
141
|
+
formula=lambda li, t: li.revenue[t] * li.margin,
|
|
142
|
+
label="Gross Profit",
|
|
143
|
+
value_format=Format.CURRENCY_NO_DECIMALS,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
base = FlexModel() # uses default margin of 0.45
|
|
147
|
+
upside = FlexModel(margin=0.52) # override at instantiation
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Use `model.compare()` to diff two instances:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
comparison = base.compare(upside, labels=["Base", "Upside"])
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Time-series formulas
|
|
159
|
+
|
|
160
|
+
Seed a value in the first period and let the formula compound from there — no `if t == first_year` guards needed:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
revenue = FormulaLine(
|
|
164
|
+
formula=lambda li, t: li.revenue[t-1] * (1 + li.growth_rate),
|
|
165
|
+
values={2024: 500_000}, # engine uses this for 2024; formula runs from 2025 onward
|
|
166
|
+
label="Revenue",
|
|
167
|
+
)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Tags
|
|
173
|
+
|
|
174
|
+
Tag line items to group them without fixed categories:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
water_sales = FixedLine(values={...}, tags=["revenue"])
|
|
178
|
+
power_sales = FixedLine(values={...}, tags=["revenue"])
|
|
179
|
+
|
|
180
|
+
# Sum all "revenue"-tagged items in a formula
|
|
181
|
+
total_revenue = FormulaLine(formula=lambda li, t: li.tag["revenue"][t])
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Tags also work in table templates:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
from pyproforma import TagTotalRow
|
|
188
|
+
TagTotalRow(tag="revenue", label="Total Revenue")
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## Tables
|
|
194
|
+
|
|
195
|
+
Generate formatted tables for HTML, Excel, or pandas. `from_template` gives full control over layout:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from pyproforma import HeaderRow, LabelRow, ItemRow, BlankRow, LineItemsTotalRow
|
|
199
|
+
|
|
200
|
+
table = model.tables.from_template([
|
|
201
|
+
HeaderRow(),
|
|
202
|
+
LabelRow("Income Statement"),
|
|
203
|
+
ItemRow("revenue"),
|
|
204
|
+
ItemRow("cogs", reverse_sign=True), # display as positive deduction
|
|
205
|
+
ItemRow("gross_profit", bold=True, borders="top"),
|
|
206
|
+
BlankRow(),
|
|
207
|
+
ItemRow("net_income", bold=True),
|
|
208
|
+
])
|
|
209
|
+
|
|
210
|
+
table.show() # inline in Jupyter
|
|
211
|
+
table.to_excel("output.xlsx") # Excel with formatting preserved
|
|
212
|
+
table.to_dataframe() # pandas DataFrame
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Convenience builders for common layouts:
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
model.tables.line_items().show() # all line items
|
|
219
|
+
model.tables.line_item("net_income", include_percent_change=True).show()
|
|
220
|
+
model.tables.precedents("net_income").show() # formula dependency tree
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Charts
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
model.charts.line_item("net_income", chart_type="bar").show()
|
|
229
|
+
model.charts.line_items(["revenue", "gross_profit", "net_income"]).show()
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Charts return a `ChartSpec` which can also render to a matplotlib `Figure`:
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
fig = model.charts.line_item("net_income").figure()
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Requires `pip install pyproforma[charts]`.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Number formatting
|
|
243
|
+
|
|
244
|
+
Named format constants flow through to both HTML and Excel output:
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
from pyproforma import Format
|
|
248
|
+
|
|
249
|
+
ItemRow("revenue", value_format=Format.CURRENCY_NO_DECIMALS) # $500,000
|
|
250
|
+
ItemRow("revenue", value_format=Format.THOUSANDS_K) # 500.0K
|
|
251
|
+
ItemRow("margin", value_format=Format.PERCENT_ONE_DECIMAL) # 45.0%
|
|
252
|
+
ItemRow("net_income", value_format=Format.MILLIONS_M) # $0.2M
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Custom formats via `NumberFormatSpec`:
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
from pyproforma import NumberFormatSpec
|
|
259
|
+
|
|
260
|
+
fmt = NumberFormatSpec(decimals=1, scale="millions", prefix="$", suffix="M")
|
|
261
|
+
# 500_000 → "$0.5M"
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## Explorer
|
|
267
|
+
|
|
268
|
+
A lightweight Flask web app for browsing any model interactively:
|
|
269
|
+
|
|
270
|
+
```python
|
|
271
|
+
from pyproforma.explorer import create_app
|
|
272
|
+
|
|
273
|
+
app = create_app(model)
|
|
274
|
+
app.run(debug=True)
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Requires `pip install pyproforma[explorer]`. The app shows all line items, their values, formula sources, and lets you update `InputLine` / `ScalarInputLine` values live. You can also pass named tables, charts, and views to build a richer dashboard.
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## Installation
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
pip install pyproforma # core only
|
|
285
|
+
pip install pyproforma[charts] # + matplotlib
|
|
286
|
+
pip install pyproforma[excel] # + openpyxl
|
|
287
|
+
pip install pyproforma[explorer] # + Flask
|
|
288
|
+
pip install pyproforma[pandas] # + pandas
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Requires Python 3.9+.
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Status
|
|
296
|
+
|
|
297
|
+
Active development. Core modeling, table export, charts, and the Flask explorer are all stable. Feedback welcome — open an issue on GitHub.
|
|
298
|
+
|
|
299
|
+
## License
|
|
300
|
+
|
|
301
|
+
MIT
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# pyproforma
|
|
2
|
+
|
|
3
|
+
A Python library for building financial models — a code-first alternative to Excel for pro formas, projections, and structured financial tables.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install pyproforma
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
Spreadsheets are the default tool for financial modeling, but they have real problems: no version control, no testing, formulas hidden inside cells, and no easy way to generate the same model for multiple scenarios. pyproforma is designed for analysts who want the benefits of code — reproducibility, testability, version history — without giving up the tabular output that finance people actually use.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## How it works
|
|
18
|
+
|
|
19
|
+
Define a model by subclassing `ProformaModel` and declaring line items as class attributes. Instantiate it with a list of periods and the library calculates everything.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from pyproforma import ProformaModel, FixedLine, FormulaLine, ScalarLine, Format
|
|
23
|
+
|
|
24
|
+
class IncomeStatement(ProformaModel):
|
|
25
|
+
default_periods = [2024, 2025, 2026]
|
|
26
|
+
|
|
27
|
+
tax_rate = ScalarLine(value=0.21, label="Tax Rate")
|
|
28
|
+
|
|
29
|
+
revenue = FixedLine(
|
|
30
|
+
values={2024: 500_000, 2025: 550_000, 2026: 605_000},
|
|
31
|
+
label="Revenue",
|
|
32
|
+
tags=["operating"],
|
|
33
|
+
value_format=Format.CURRENCY_NO_DECIMALS,
|
|
34
|
+
)
|
|
35
|
+
cogs = FormulaLine(
|
|
36
|
+
formula=lambda li, t: li.revenue[t] * 0.55,
|
|
37
|
+
label="Cost of Goods Sold",
|
|
38
|
+
value_format=Format.CURRENCY_NO_DECIMALS,
|
|
39
|
+
)
|
|
40
|
+
gross_profit = FormulaLine(
|
|
41
|
+
formula=lambda li, t: li.revenue[t] - li.cogs[t],
|
|
42
|
+
label="Gross Profit",
|
|
43
|
+
value_format=Format.CURRENCY_NO_DECIMALS,
|
|
44
|
+
)
|
|
45
|
+
net_income = FormulaLine(
|
|
46
|
+
formula=lambda li, t: li.gross_profit[t] * (1 - li.tax_rate),
|
|
47
|
+
label="Net Income",
|
|
48
|
+
value_format=Format.CURRENCY_NO_DECIMALS,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
model = IncomeStatement() # uses default_periods
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Access results with dot notation (primary) or bracket notation (useful when the name is in a variable):
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
model.net_income[2025] # 173_745.0 — dot notation
|
|
58
|
+
model["net_income"][2025] # same value — bracket notation
|
|
59
|
+
|
|
60
|
+
model.tax_rate.value # 0.21 — scalars have .value, not [t]
|
|
61
|
+
|
|
62
|
+
model.periods # [2024, 2025, 2026]
|
|
63
|
+
model.line_item_names # ["revenue", "cogs", "gross_profit", "net_income"]
|
|
64
|
+
model.scalar_names # ["tax_rate"]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Line item types
|
|
70
|
+
|
|
71
|
+
| Type | Use |
|
|
72
|
+
|------|-----|
|
|
73
|
+
| `FixedLine(values, ...)` | Hardcoded values per period |
|
|
74
|
+
| `FormulaLine(formula, ...)` | Calculated from other items via a lambda |
|
|
75
|
+
| `ScalarLine(value, ...)` | A single value shared across all periods |
|
|
76
|
+
| `InputLine(default, ...)` | Period-indexed values supplied at instantiation |
|
|
77
|
+
| `ScalarInputLine(default, ...)` | A single value supplied at instantiation |
|
|
78
|
+
|
|
79
|
+
Formula lambdas receive `li` (the model namespace) and `t` (the current period). Period-indexed items use `li.name[t]`; scalars use `li.name` (no `[t]`). Reference prior periods with `li.name[t-1]`.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Scenario inputs
|
|
84
|
+
|
|
85
|
+
`InputLine` and `ScalarInputLine` let callers supply values at instantiation without subclassing again — useful for scenario analysis.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from pyproforma import InputLine, ScalarInputLine
|
|
89
|
+
|
|
90
|
+
class FlexModel(ProformaModel):
|
|
91
|
+
default_periods = [2024, 2025, 2026]
|
|
92
|
+
|
|
93
|
+
margin = ScalarInputLine(default=0.45, label="Gross Margin")
|
|
94
|
+
revenue = FixedLine(
|
|
95
|
+
values={2024: 500_000, 2025: 550_000, 2026: 605_000},
|
|
96
|
+
label="Revenue",
|
|
97
|
+
value_format=Format.CURRENCY_NO_DECIMALS,
|
|
98
|
+
)
|
|
99
|
+
gross_profit = FormulaLine(
|
|
100
|
+
formula=lambda li, t: li.revenue[t] * li.margin,
|
|
101
|
+
label="Gross Profit",
|
|
102
|
+
value_format=Format.CURRENCY_NO_DECIMALS,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
base = FlexModel() # uses default margin of 0.45
|
|
106
|
+
upside = FlexModel(margin=0.52) # override at instantiation
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Use `model.compare()` to diff two instances:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
comparison = base.compare(upside, labels=["Base", "Upside"])
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Time-series formulas
|
|
118
|
+
|
|
119
|
+
Seed a value in the first period and let the formula compound from there — no `if t == first_year` guards needed:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
revenue = FormulaLine(
|
|
123
|
+
formula=lambda li, t: li.revenue[t-1] * (1 + li.growth_rate),
|
|
124
|
+
values={2024: 500_000}, # engine uses this for 2024; formula runs from 2025 onward
|
|
125
|
+
label="Revenue",
|
|
126
|
+
)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Tags
|
|
132
|
+
|
|
133
|
+
Tag line items to group them without fixed categories:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
water_sales = FixedLine(values={...}, tags=["revenue"])
|
|
137
|
+
power_sales = FixedLine(values={...}, tags=["revenue"])
|
|
138
|
+
|
|
139
|
+
# Sum all "revenue"-tagged items in a formula
|
|
140
|
+
total_revenue = FormulaLine(formula=lambda li, t: li.tag["revenue"][t])
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Tags also work in table templates:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from pyproforma import TagTotalRow
|
|
147
|
+
TagTotalRow(tag="revenue", label="Total Revenue")
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Tables
|
|
153
|
+
|
|
154
|
+
Generate formatted tables for HTML, Excel, or pandas. `from_template` gives full control over layout:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from pyproforma import HeaderRow, LabelRow, ItemRow, BlankRow, LineItemsTotalRow
|
|
158
|
+
|
|
159
|
+
table = model.tables.from_template([
|
|
160
|
+
HeaderRow(),
|
|
161
|
+
LabelRow("Income Statement"),
|
|
162
|
+
ItemRow("revenue"),
|
|
163
|
+
ItemRow("cogs", reverse_sign=True), # display as positive deduction
|
|
164
|
+
ItemRow("gross_profit", bold=True, borders="top"),
|
|
165
|
+
BlankRow(),
|
|
166
|
+
ItemRow("net_income", bold=True),
|
|
167
|
+
])
|
|
168
|
+
|
|
169
|
+
table.show() # inline in Jupyter
|
|
170
|
+
table.to_excel("output.xlsx") # Excel with formatting preserved
|
|
171
|
+
table.to_dataframe() # pandas DataFrame
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Convenience builders for common layouts:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
model.tables.line_items().show() # all line items
|
|
178
|
+
model.tables.line_item("net_income", include_percent_change=True).show()
|
|
179
|
+
model.tables.precedents("net_income").show() # formula dependency tree
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Charts
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
model.charts.line_item("net_income", chart_type="bar").show()
|
|
188
|
+
model.charts.line_items(["revenue", "gross_profit", "net_income"]).show()
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Charts return a `ChartSpec` which can also render to a matplotlib `Figure`:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
fig = model.charts.line_item("net_income").figure()
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Requires `pip install pyproforma[charts]`.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Number formatting
|
|
202
|
+
|
|
203
|
+
Named format constants flow through to both HTML and Excel output:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
from pyproforma import Format
|
|
207
|
+
|
|
208
|
+
ItemRow("revenue", value_format=Format.CURRENCY_NO_DECIMALS) # $500,000
|
|
209
|
+
ItemRow("revenue", value_format=Format.THOUSANDS_K) # 500.0K
|
|
210
|
+
ItemRow("margin", value_format=Format.PERCENT_ONE_DECIMAL) # 45.0%
|
|
211
|
+
ItemRow("net_income", value_format=Format.MILLIONS_M) # $0.2M
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Custom formats via `NumberFormatSpec`:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
from pyproforma import NumberFormatSpec
|
|
218
|
+
|
|
219
|
+
fmt = NumberFormatSpec(decimals=1, scale="millions", prefix="$", suffix="M")
|
|
220
|
+
# 500_000 → "$0.5M"
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Explorer
|
|
226
|
+
|
|
227
|
+
A lightweight Flask web app for browsing any model interactively:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
from pyproforma.explorer import create_app
|
|
231
|
+
|
|
232
|
+
app = create_app(model)
|
|
233
|
+
app.run(debug=True)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Requires `pip install pyproforma[explorer]`. The app shows all line items, their values, formula sources, and lets you update `InputLine` / `ScalarInputLine` values live. You can also pass named tables, charts, and views to build a richer dashboard.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Installation
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
pip install pyproforma # core only
|
|
244
|
+
pip install pyproforma[charts] # + matplotlib
|
|
245
|
+
pip install pyproforma[excel] # + openpyxl
|
|
246
|
+
pip install pyproforma[explorer] # + Flask
|
|
247
|
+
pip install pyproforma[pandas] # + pandas
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Requires Python 3.9+.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## Status
|
|
255
|
+
|
|
256
|
+
Active development. Core modeling, table export, charts, and the Flask explorer are all stable. Feedback welcome — open an issue on GitHub.
|
|
257
|
+
|
|
258
|
+
## License
|
|
259
|
+
|
|
260
|
+
MIT
|
|
@@ -51,7 +51,18 @@ def create_app(model, *, tables=None, charts=None, views=None, home_view=None):
|
|
|
51
51
|
state.model = model
|
|
52
52
|
state.model_class = type(model)
|
|
53
53
|
state.periods = model.periods
|
|
54
|
-
|
|
54
|
+
|
|
55
|
+
all_input_names = type(model)._scalar_input_names + type(model)._input_line_names
|
|
56
|
+
state.inputs_group = (
|
|
57
|
+
InputGroup(names=all_input_names, orient="horizontal")
|
|
58
|
+
if all_input_names else None
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
import openpyxl # noqa: F401
|
|
63
|
+
state.excel_available = True
|
|
64
|
+
except ImportError:
|
|
65
|
+
state.excel_available = False
|
|
55
66
|
|
|
56
67
|
all_items_def = TableDef(
|
|
57
68
|
rows=[HeaderRow(), *[ItemRow(name=n) for n in model.line_item_names]],
|
|
@@ -89,6 +100,8 @@ def create_app(model, *, tables=None, charts=None, views=None, home_view=None):
|
|
|
89
100
|
"nav_charts": list(enumerate(state.charts.keys())),
|
|
90
101
|
"nav_views": list(enumerate(state.views.keys())),
|
|
91
102
|
"nav_tags": state.model.tags,
|
|
103
|
+
"nav_has_inputs": state.inputs_group is not None,
|
|
104
|
+
"excel_available": state.excel_available,
|
|
92
105
|
}
|
|
93
106
|
|
|
94
107
|
def _format_name(spec):
|
|
@@ -123,29 +136,6 @@ def create_app(model, *, tables=None, charts=None, views=None, home_view=None):
|
|
|
123
136
|
})
|
|
124
137
|
return items
|
|
125
138
|
|
|
126
|
-
def _build_inputs():
|
|
127
|
-
m = state.model
|
|
128
|
-
inputs = []
|
|
129
|
-
for name in state.model_class._scalar_input_names:
|
|
130
|
-
spec = getattr(state.model_class, name)
|
|
131
|
-
inputs.append({
|
|
132
|
-
"name": name,
|
|
133
|
-
"label": spec.label or name,
|
|
134
|
-
"is_scalar": True,
|
|
135
|
-
"value": m._scalars[name],
|
|
136
|
-
"formatted_values": [m[name].formatted_value],
|
|
137
|
-
})
|
|
138
|
-
for name in state.model_class._input_line_names:
|
|
139
|
-
spec = getattr(state.model_class, name)
|
|
140
|
-
inputs.append({
|
|
141
|
-
"name": name,
|
|
142
|
-
"label": spec.label or name,
|
|
143
|
-
"is_scalar": False,
|
|
144
|
-
"value": m._input_line_values.get(name, {}),
|
|
145
|
-
"formatted_values": [m[name].formatted_value(p) for p in state.periods],
|
|
146
|
-
})
|
|
147
|
-
return inputs
|
|
148
|
-
|
|
149
139
|
def _add_hrefs(definition):
|
|
150
140
|
rows = definition.rows if isinstance(definition, TableDef) else definition
|
|
151
141
|
result = []
|
|
@@ -285,11 +275,33 @@ def create_app(model, *, tables=None, charts=None, views=None, home_view=None):
|
|
|
285
275
|
label = labels[idx]
|
|
286
276
|
definition = state.tables[label]
|
|
287
277
|
table = state.model.tables.build(_add_hrefs(definition))
|
|
278
|
+
download_url = url_for("table_download", idx=idx) if state.excel_available else None
|
|
288
279
|
return render_template(
|
|
289
280
|
"table_view.html",
|
|
290
281
|
model=state.model,
|
|
291
282
|
title=table.title or label,
|
|
292
283
|
table_html=table.to_bootstrap_html(),
|
|
284
|
+
download_url=download_url,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
@app.route("/table/<int:idx>/download")
|
|
288
|
+
def table_download(idx):
|
|
289
|
+
labels = list(state.tables.keys())
|
|
290
|
+
if idx >= len(labels):
|
|
291
|
+
abort(404)
|
|
292
|
+
if not state.excel_available:
|
|
293
|
+
abort(501)
|
|
294
|
+
from flask import send_file
|
|
295
|
+
label = labels[idx]
|
|
296
|
+
definition = state.tables[label]
|
|
297
|
+
table = state.model.tables.build(_add_hrefs(definition))
|
|
298
|
+
buf = table.to_excel_bytes()
|
|
299
|
+
filename = label.lower().replace(" ", "_") + ".xlsx"
|
|
300
|
+
return send_file(
|
|
301
|
+
buf,
|
|
302
|
+
as_attachment=True,
|
|
303
|
+
download_name=filename,
|
|
304
|
+
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
293
305
|
)
|
|
294
306
|
|
|
295
307
|
@app.route("/chart/<int:idx>")
|
|
@@ -354,6 +366,11 @@ def create_app(model, *, tables=None, charts=None, views=None, home_view=None):
|
|
|
354
366
|
c["col_width"] = col_width
|
|
355
367
|
c["html"] = built.to_bootstrap_html()
|
|
356
368
|
c["table_title"] = built.title or comp["ref"]
|
|
369
|
+
if state.excel_available:
|
|
370
|
+
table_idx = list(state.tables.keys()).index(comp["ref"])
|
|
371
|
+
c["download_url"] = url_for("table_download", idx=table_idx)
|
|
372
|
+
else:
|
|
373
|
+
c["download_url"] = None
|
|
357
374
|
processed.append(c)
|
|
358
375
|
rows.append(processed)
|
|
359
376
|
|
|
@@ -368,14 +385,25 @@ def create_app(model, *, tables=None, charts=None, views=None, home_view=None):
|
|
|
368
385
|
|
|
369
386
|
@app.route("/inputs", methods=["GET"])
|
|
370
387
|
def inputs():
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
388
|
+
if state.inputs_group is None:
|
|
389
|
+
return render_template(
|
|
390
|
+
"view.html",
|
|
391
|
+
model=state.model,
|
|
392
|
+
title="Inputs",
|
|
393
|
+
rows=[],
|
|
394
|
+
has_inputs=False,
|
|
395
|
+
form_action=None,
|
|
396
|
+
empty_message="This model has no input line items.",
|
|
397
|
+
)
|
|
398
|
+
built = state.inputs_group.build(state.model)
|
|
399
|
+
built["col_width"] = 12
|
|
374
400
|
return render_template(
|
|
375
|
-
"
|
|
376
|
-
model=
|
|
377
|
-
|
|
378
|
-
|
|
401
|
+
"view.html",
|
|
402
|
+
model=state.model,
|
|
403
|
+
title="Inputs",
|
|
404
|
+
rows=[[built]],
|
|
405
|
+
has_inputs=True,
|
|
406
|
+
form_action=url_for("update_inputs"),
|
|
379
407
|
)
|
|
380
408
|
|
|
381
409
|
@app.route("/inputs", methods=["POST"])
|
|
@@ -402,10 +430,9 @@ def create_app(model, *, tables=None, charts=None, views=None, home_view=None):
|
|
|
402
430
|
else:
|
|
403
431
|
kwargs[name] = {p: v for p, v in current.items() if p not in locked}
|
|
404
432
|
state.model = state.model_class(periods=state.periods, **kwargs)
|
|
405
|
-
|
|
406
|
-
flash("Model updated.")
|
|
433
|
+
flash("Model updated.", "success")
|
|
407
434
|
except Exception as e:
|
|
408
|
-
|
|
435
|
+
flash(str(e), "danger")
|
|
409
436
|
next_url = request.args.get("next") or url_for("inputs")
|
|
410
437
|
return redirect(next_url)
|
|
411
438
|
|