pyproforma 0.2.0__tar.gz → 0.2.1__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.0/pyproforma.egg-info → pyproforma-0.2.1}/PKG-INFO +1 -2
- {pyproforma-0.2.0 → pyproforma-0.2.1}/README.md +0 -1
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/__init__.py +27 -11
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/calculation_engine.py +12 -85
- pyproforma-0.2.1/pyproforma/chart/__init__.py +3 -0
- pyproforma-0.2.1/pyproforma/chart/chart.py +107 -0
- pyproforma-0.2.1/pyproforma/chart/renderers/__init__.py +4 -0
- pyproforma-0.2.1/pyproforma/chart/renderers/base.py +17 -0
- pyproforma-0.2.1/pyproforma/chart/renderers/matplotlib_renderer.py +115 -0
- pyproforma-0.2.1/pyproforma/charts/__init__.py +3 -0
- pyproforma-0.2.1/pyproforma/charts/chart_def.py +45 -0
- pyproforma-0.2.1/pyproforma/charts/charts.py +185 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/compare/model_comparison.py +5 -5
- pyproforma-0.2.1/pyproforma/line_items/debt_line.py +262 -0
- pyproforma-0.2.1/pyproforma/line_items/fixed_line.py +72 -0
- pyproforma-0.2.1/pyproforma/line_items/formula_line.py +210 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/line_items/input_line.py +41 -11
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/line_items/line_item_result.py +92 -3
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/line_items/line_item_values.py +4 -4
- pyproforma-0.2.1/pyproforma/model_namespace.py +68 -0
- pyproforma-0.2.1/pyproforma/proforma_model.py +191 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/reserved_words.py +1 -0
- pyproforma-0.2.1/pyproforma/table/bootstrap_html_renderer.py +120 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/table/format_value.py +3 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/table/html_renderer.py +4 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/table/table_class.py +25 -3
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/tables/__init__.py +2 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/tables/row_types.py +14 -10
- pyproforma-0.2.1/pyproforma/tables/table_def.py +36 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/tables/tables.py +29 -26
- {pyproforma-0.2.0 → pyproforma-0.2.1/pyproforma.egg-info}/PKG-INFO +1 -2
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma.egg-info/SOURCES.txt +10 -4
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproject.toml +1 -1
- pyproforma-0.2.0/pyproforma/assumption.py +0 -70
- pyproforma-0.2.0/pyproforma/assumption_result.py +0 -129
- pyproforma-0.2.0/pyproforma/assumption_values.py +0 -75
- pyproforma-0.2.0/pyproforma/input_assumption.py +0 -90
- pyproforma-0.2.0/pyproforma/line_items/debt_line.py +0 -528
- pyproforma-0.2.0/pyproforma/line_items/fixed_line.py +0 -75
- pyproforma-0.2.0/pyproforma/line_items/formula_line.py +0 -210
- pyproforma-0.2.0/pyproforma/model_namespace.py +0 -82
- pyproforma-0.2.0/pyproforma/proforma_model.py +0 -370
- {pyproforma-0.2.0 → pyproforma-0.2.1}/LICENSE +0 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/MANIFEST.in +0 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/compare/__init__.py +0 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/line_items/__init__.py +0 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/line_items/line_item.py +0 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/line_items/line_item_selection.py +0 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/table/__init__.py +0 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/table/colors.py +0 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/table/excel.py +0 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/tags_namespace.py +0 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma.egg-info/dependency_links.txt +0 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma.egg-info/requires.txt +0 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma.egg-info/top_level.txt +0 -0
- {pyproforma-0.2.0 → pyproforma-0.2.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyproforma
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: A Python package for financial modeling and reporting
|
|
5
5
|
Author-email: Robert Hannay <rhannay@gmail.com>
|
|
6
6
|
Maintainer-email: Robert Hannay <rhannay@gmail.com>
|
|
@@ -146,7 +146,6 @@ class FlexModel(ProformaModel):
|
|
|
146
146
|
gross_profit = FormulaLine(formula=lambda li, t: li.revenue[t] - li.cogs[t])
|
|
147
147
|
|
|
148
148
|
base = FlexModel(periods=[2024, 2025])
|
|
149
|
-
# InputAssumption lets callers override at instantiation time — coming soon
|
|
150
149
|
```
|
|
151
150
|
|
|
152
151
|
Use `ModelComparison` to diff two model instances:
|
|
@@ -109,7 +109,6 @@ class FlexModel(ProformaModel):
|
|
|
109
109
|
gross_profit = FormulaLine(formula=lambda li, t: li.revenue[t] - li.cogs[t])
|
|
110
110
|
|
|
111
111
|
base = FlexModel(periods=[2024, 2025])
|
|
112
|
-
# InputAssumption lets callers override at instantiation time — coming soon
|
|
113
112
|
```
|
|
114
113
|
|
|
115
114
|
Use `ModelComparison` to diff two model instances:
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
"""
|
|
2
|
-
PyProforma
|
|
3
|
-
|
|
4
|
-
Version 2 provides a cleaner, Pydantic-inspired API for building financial models.
|
|
2
|
+
PyProforma - A lightweight financial modeling framework.
|
|
5
3
|
"""
|
|
6
4
|
|
|
7
|
-
from .
|
|
8
|
-
from .
|
|
9
|
-
from .
|
|
10
|
-
|
|
5
|
+
from .charts.chart_def import ChartDef
|
|
6
|
+
from .tables.table_def import TableDef
|
|
7
|
+
from .tables.row_types import (
|
|
8
|
+
BlankRow,
|
|
9
|
+
CumulativeChangeRow,
|
|
10
|
+
CumulativePercentChangeRow,
|
|
11
|
+
HeaderRow,
|
|
12
|
+
ItemRow,
|
|
13
|
+
LabelRow,
|
|
14
|
+
LineItemsTotalRow,
|
|
15
|
+
PercentChangeRow,
|
|
16
|
+
TagItemsRow,
|
|
17
|
+
TagTotalRow,
|
|
18
|
+
)
|
|
11
19
|
from .line_items import (
|
|
12
20
|
DebtCalculator,
|
|
13
21
|
DebtInterestLine,
|
|
@@ -30,18 +38,26 @@ from .tags_namespace import ModelTagNamespace
|
|
|
30
38
|
|
|
31
39
|
__all__ = [
|
|
32
40
|
"ProformaModel",
|
|
41
|
+
"ChartDef",
|
|
42
|
+
"TableDef",
|
|
43
|
+
"HeaderRow",
|
|
44
|
+
"ItemRow",
|
|
45
|
+
"LabelRow",
|
|
46
|
+
"BlankRow",
|
|
47
|
+
"TagItemsRow",
|
|
48
|
+
"TagTotalRow",
|
|
49
|
+
"LineItemsTotalRow",
|
|
50
|
+
"PercentChangeRow",
|
|
51
|
+
"CumulativeChangeRow",
|
|
52
|
+
"CumulativePercentChangeRow",
|
|
33
53
|
"FixedLine",
|
|
34
54
|
"FormulaLine",
|
|
35
55
|
"InputLine",
|
|
36
|
-
"InputAssumption",
|
|
37
56
|
"DebtPrincipalLine",
|
|
38
57
|
"DebtInterestLine",
|
|
39
58
|
"DebtCalculator",
|
|
40
59
|
"create_debt_lines",
|
|
41
|
-
"Assumption",
|
|
42
|
-
"AssumptionResult",
|
|
43
60
|
"LineItem",
|
|
44
|
-
"AssumptionValues",
|
|
45
61
|
"LineItemValues",
|
|
46
62
|
"LineItemValue",
|
|
47
63
|
"LineItemResult",
|
|
@@ -1,49 +1,32 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Calculation engine for
|
|
2
|
+
Calculation engine for ProformaModel.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
Calculates line item values from formulas, handling dependencies and resolving
|
|
5
|
+
values across periods.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from typing import TYPE_CHECKING, Any
|
|
9
9
|
|
|
10
10
|
if TYPE_CHECKING:
|
|
11
|
-
from .assumption_values import AssumptionValues
|
|
12
11
|
from .line_items.line_item_values import LineItemValues
|
|
13
12
|
|
|
14
13
|
|
|
15
14
|
def calculate_line_items(
|
|
16
15
|
model: Any,
|
|
17
|
-
|
|
16
|
+
scalars: dict,
|
|
18
17
|
periods: list[int],
|
|
19
18
|
) -> "LineItemValues":
|
|
20
19
|
"""
|
|
21
20
|
Calculate all line item values for the given model.
|
|
22
21
|
|
|
23
|
-
This function processes formulas in dependency order, evaluates them for
|
|
24
|
-
each period, and returns a populated LineItemValues container.
|
|
25
|
-
|
|
26
|
-
Formulas receive three parameters:
|
|
27
|
-
- a (AssumptionValues): Access assumptions via a.tax_rate, a.growth_rate, etc.
|
|
28
|
-
- li (LineItemValues): Access line items via li.revenue[t], li.revenue[t-1], etc.
|
|
29
|
-
- t (int): Current period being calculated
|
|
30
|
-
|
|
31
|
-
This function processes each period in order, calculating all line items
|
|
32
|
-
for that period before moving to the next. This allows for time-offset
|
|
33
|
-
references (e.g., li.revenue[t-1]) to work correctly.
|
|
34
|
-
|
|
35
22
|
Args:
|
|
36
23
|
model: The ProformaModel instance containing line item definitions.
|
|
37
|
-
|
|
38
|
-
periods
|
|
24
|
+
scalars: Dict of scalar line item values (FixedLine(value=) or scalar InputLine).
|
|
25
|
+
periods: List of periods to calculate.
|
|
39
26
|
|
|
40
27
|
Returns:
|
|
41
28
|
LineItemValues: Populated container with all calculated values.
|
|
42
|
-
|
|
43
|
-
Raises:
|
|
44
|
-
ValueError: If line items are not found or calculation fails.
|
|
45
29
|
"""
|
|
46
|
-
# Import here to avoid circular imports
|
|
47
30
|
from .line_items.debt_line import DebtBase
|
|
48
31
|
from .line_items.fixed_line import FixedLine
|
|
49
32
|
from .line_items.formula_line import FormulaLine
|
|
@@ -51,10 +34,8 @@ def calculate_line_items(
|
|
|
51
34
|
from .line_items.line_item_values import LineItemValues
|
|
52
35
|
from .model_namespace import ModelNamespace
|
|
53
36
|
|
|
54
|
-
# Initialize line item values with registered names for validation
|
|
55
37
|
li = LineItemValues(periods=periods, names=model.line_item_names, model=model)
|
|
56
38
|
|
|
57
|
-
# Separate fixed and formula line items
|
|
58
39
|
fixed_items = []
|
|
59
40
|
formula_items = []
|
|
60
41
|
|
|
@@ -65,18 +46,14 @@ def calculate_line_items(
|
|
|
65
46
|
elif isinstance(line_item, (FormulaLine, DebtBase)):
|
|
66
47
|
formula_items.append(name)
|
|
67
48
|
|
|
68
|
-
# Calculate values for each period
|
|
69
49
|
for period in periods:
|
|
70
|
-
|
|
71
|
-
ns = ModelNamespace(li, av)
|
|
50
|
+
ns = ModelNamespace(li, scalars)
|
|
72
51
|
|
|
73
|
-
# First, calculate all fixed line items (they don't depend on other line items)
|
|
74
52
|
for name in fixed_items:
|
|
75
53
|
line_item = getattr(model.__class__, name)
|
|
76
54
|
value = _calculate_single_line_item(line_item, ns, period, model)
|
|
77
55
|
li.set(name, period, value)
|
|
78
56
|
|
|
79
|
-
# Then calculate formula items with dependency resolution
|
|
80
57
|
remaining = formula_items.copy()
|
|
81
58
|
max_iterations = len(formula_items) + 1
|
|
82
59
|
iteration = 0
|
|
@@ -91,32 +68,21 @@ def calculate_line_items(
|
|
|
91
68
|
value = _calculate_single_line_item(line_item, ns, period, model)
|
|
92
69
|
li.set(name, period, value)
|
|
93
70
|
except AttributeError as e:
|
|
94
|
-
# AttributeError means accessing unregistered line item (typo)
|
|
95
|
-
# LineItemValues now raises helpful error with available names
|
|
96
71
|
error_msg = str(e)
|
|
97
72
|
if "is not registered" in error_msg:
|
|
98
|
-
# This is a typo - raise immediately with helpful message
|
|
99
73
|
raise ValueError(
|
|
100
74
|
f"Error in formula for '{line_item.name}': {e}"
|
|
101
75
|
) from e
|
|
102
|
-
# Otherwise, it's some other AttributeError - retry
|
|
103
76
|
still_pending.append(name)
|
|
104
77
|
except KeyError as e:
|
|
105
|
-
# KeyError could mean:
|
|
106
|
-
# 1. Dependency not yet calculated for current period (retry)
|
|
107
|
-
# 2. Accessing a period not in the model (fatal error)
|
|
108
|
-
# Check if the error is about the current period
|
|
109
78
|
error_msg = str(e)
|
|
110
79
|
if f"Period {period}" in error_msg:
|
|
111
|
-
# Dependency not yet calculated for current period - retry
|
|
112
80
|
still_pending.append(name)
|
|
113
81
|
else:
|
|
114
|
-
# Accessing a period outside the model - wrap in ValueError
|
|
115
82
|
raise ValueError(
|
|
116
83
|
f"Error evaluating formula for '{line_item.name}' in period {period}: {e}"
|
|
117
84
|
) from e
|
|
118
85
|
|
|
119
|
-
# If we made no progress, we have a circular reference
|
|
120
86
|
if len(still_pending) == len(remaining):
|
|
121
87
|
raise ValueError(
|
|
122
88
|
f"Circular reference detected for period {period}. "
|
|
@@ -133,36 +99,12 @@ def _calculate_single_line_item(
|
|
|
133
99
|
ns: Any,
|
|
134
100
|
period: int,
|
|
135
101
|
model: Any = None,
|
|
136
|
-
) ->
|
|
137
|
-
"""
|
|
138
|
-
Calculate the value of a single line item for a specific period.
|
|
139
|
-
|
|
140
|
-
This is a pure function that computes and returns a value without modifying
|
|
141
|
-
the LineItemValues container. The caller is responsible for storing the result.
|
|
142
|
-
|
|
143
|
-
Args:
|
|
144
|
-
line_item: The line item definition (FixedLine, FormulaLine, etc.).
|
|
145
|
-
Must have a .name attribute set by __set_name__.
|
|
146
|
-
ns (ModelNamespace): Unified namespace giving formulas access to both
|
|
147
|
-
line items and assumptions via a single object.
|
|
148
|
-
period (int): The period to calculate for.
|
|
149
|
-
model: The ProformaModel instance (needed for InputLine value lookup).
|
|
150
|
-
|
|
151
|
-
Returns:
|
|
152
|
-
float: The calculated value.
|
|
153
|
-
|
|
154
|
-
Raises:
|
|
155
|
-
ValueError: If calculation fails.
|
|
156
|
-
AttributeError: If a dependency is not yet calculated.
|
|
157
|
-
KeyError: If accessing a period not yet calculated.
|
|
158
|
-
"""
|
|
159
|
-
# Import here to avoid circular imports
|
|
102
|
+
) -> Any:
|
|
160
103
|
from .line_items.debt_line import DebtBase
|
|
161
104
|
from .line_items.fixed_line import FixedLine
|
|
162
105
|
from .line_items.formula_line import FormulaLine
|
|
163
106
|
from .line_items.input_line import InputLine
|
|
164
107
|
|
|
165
|
-
# Handle InputLine — values live on the model instance
|
|
166
108
|
if isinstance(line_item, InputLine):
|
|
167
109
|
input_values = getattr(model, "_input_line_values", {})
|
|
168
110
|
period_values = input_values.get(line_item.name, {})
|
|
@@ -171,9 +113,8 @@ def _calculate_single_line_item(
|
|
|
171
113
|
raise ValueError(
|
|
172
114
|
f"No input value for '{line_item.name}' in period {period}"
|
|
173
115
|
)
|
|
174
|
-
return
|
|
116
|
+
return value
|
|
175
117
|
|
|
176
|
-
# Handle FixedLine
|
|
177
118
|
if isinstance(line_item, FixedLine):
|
|
178
119
|
value = line_item.get_value(period)
|
|
179
120
|
if value is None:
|
|
@@ -182,50 +123,36 @@ def _calculate_single_line_item(
|
|
|
182
123
|
)
|
|
183
124
|
return value
|
|
184
125
|
|
|
185
|
-
# Handle FormulaLine
|
|
186
126
|
if isinstance(line_item, FormulaLine):
|
|
187
|
-
# Check for override value first
|
|
188
127
|
if period in line_item.values:
|
|
189
128
|
return line_item.values[period]
|
|
190
|
-
|
|
191
129
|
try:
|
|
192
130
|
value = line_item.eval(ns, period)
|
|
193
131
|
except (AttributeError, KeyError):
|
|
194
|
-
# Re-raise these - indicate missing dependencies or periods
|
|
195
|
-
# The caller will decide whether to retry or raise ValueError
|
|
196
132
|
raise
|
|
197
133
|
except Exception as e:
|
|
198
134
|
raise ValueError(
|
|
199
135
|
f"Error evaluating formula for '{line_item.name}' in period {period}: {e}"
|
|
200
136
|
) from e
|
|
201
|
-
|
|
202
|
-
# Validate the result type
|
|
203
|
-
if not isinstance(value, (int, float)):
|
|
137
|
+
if value is None:
|
|
204
138
|
raise ValueError(
|
|
205
|
-
f"Formula for '{line_item.name}' returned
|
|
139
|
+
f"Formula for '{line_item.name}' returned None"
|
|
206
140
|
)
|
|
141
|
+
return value
|
|
207
142
|
|
|
208
|
-
return float(value)
|
|
209
|
-
|
|
210
|
-
# Handle DebtBase (DebtPrincipalLine, DebtInterestLine)
|
|
211
143
|
if isinstance(line_item, DebtBase):
|
|
212
144
|
try:
|
|
213
145
|
value = line_item.eval(ns, period)
|
|
214
146
|
except (AttributeError, KeyError):
|
|
215
|
-
# Re-raise these - indicate missing dependencies or periods
|
|
216
|
-
# The caller will decide whether to retry or raise ValueError
|
|
217
147
|
raise
|
|
218
148
|
except Exception as e:
|
|
219
149
|
raise ValueError(
|
|
220
150
|
f"Error evaluating debt line for '{line_item.name}' in period {period}: {e}"
|
|
221
151
|
) from e
|
|
222
|
-
|
|
223
|
-
# Validate the result type
|
|
224
152
|
if not isinstance(value, (int, float)):
|
|
225
153
|
raise ValueError(
|
|
226
154
|
f"Debt line '{line_item.name}' returned invalid type: {type(value)}"
|
|
227
155
|
)
|
|
228
|
-
|
|
229
156
|
return float(value)
|
|
230
157
|
|
|
231
158
|
raise ValueError(f"Unknown line item type for '{line_item.name}'")
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Chart — generic chart data class, rendering-backend agnostic.
|
|
3
|
+
|
|
4
|
+
Chart and ChartSeries are the intermediate representation between any data
|
|
5
|
+
source and a rendering backend. They are not specific to ProformaModel —
|
|
6
|
+
anything that can populate a list of ChartSeries can produce a Chart.
|
|
7
|
+
|
|
8
|
+
Rendering backends:
|
|
9
|
+
- MatplotlibRenderer → .show() / .figure() for Jupyter and scripts
|
|
10
|
+
- .to_apexcharts() → JSON-ready dict for ApexCharts in web apps
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import TYPE_CHECKING, Literal
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from pyproforma.table.format_value import NumberFormatSpec
|
|
20
|
+
|
|
21
|
+
ChartType = Literal["line", "bar", "stacked_bar"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ChartSeries:
|
|
26
|
+
"""A single data series for a chart."""
|
|
27
|
+
|
|
28
|
+
label: str
|
|
29
|
+
x_values: list[int]
|
|
30
|
+
y_values: list[float]
|
|
31
|
+
color: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Chart:
|
|
36
|
+
"""
|
|
37
|
+
Intermediate representation of a chart — pure data, rendering-backend agnostic.
|
|
38
|
+
|
|
39
|
+
Build one via model.charts.line_item() or model.charts.line_items(), then
|
|
40
|
+
render it with .show() / .figure() (matplotlib) or .to_apexcharts() (web/JSON).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
series: list[ChartSeries]
|
|
44
|
+
chart_type: ChartType = "line"
|
|
45
|
+
title: str | None = None
|
|
46
|
+
x_label: str | None = None
|
|
47
|
+
y_label: str | None = None
|
|
48
|
+
value_format: "NumberFormatSpec | None" = None
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
# Matplotlib convenience (lazy import — matplotlib not required at import time)
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def figure(self, figsize: tuple[float, float] = (10, 6)):
|
|
55
|
+
"""Return a matplotlib Figure for this chart."""
|
|
56
|
+
from pyproforma.chart.renderers.matplotlib_renderer import MatplotlibRenderer
|
|
57
|
+
return MatplotlibRenderer().render(self, figsize=figsize)
|
|
58
|
+
|
|
59
|
+
def show(self, figsize: tuple[float, float] = (10, 6)) -> None:
|
|
60
|
+
"""Render and display this chart via matplotlib."""
|
|
61
|
+
import matplotlib.pyplot as plt
|
|
62
|
+
self.figure(figsize=figsize)
|
|
63
|
+
plt.show()
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
# Web / serialisation
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def to_apexcharts(self) -> dict:
|
|
70
|
+
"""Return a dict of ApexCharts options ready for JSON serialization."""
|
|
71
|
+
result = {
|
|
72
|
+
"series": [
|
|
73
|
+
{"name": s.label, "data": s.y_values}
|
|
74
|
+
for s in self.series
|
|
75
|
+
],
|
|
76
|
+
"categories": self.series[0].x_values if self.series else [],
|
|
77
|
+
"title": self.title or "",
|
|
78
|
+
"chart_type": self.chart_type,
|
|
79
|
+
"yaxis_format": self.value_format.to_dict() if self.value_format else None,
|
|
80
|
+
}
|
|
81
|
+
colors = [s.color for s in self.series]
|
|
82
|
+
if any(c is not None for c in colors):
|
|
83
|
+
result["colors"] = colors
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
def to_dict(self) -> dict:
|
|
87
|
+
"""Serialise this Chart to a plain dict suitable for JSON / web renderers."""
|
|
88
|
+
return {
|
|
89
|
+
"chart_type": self.chart_type,
|
|
90
|
+
"title": self.title,
|
|
91
|
+
"x_label": self.x_label,
|
|
92
|
+
"y_label": self.y_label,
|
|
93
|
+
"series": [
|
|
94
|
+
{
|
|
95
|
+
"label": s.label,
|
|
96
|
+
"x_values": s.x_values,
|
|
97
|
+
"y_values": s.y_values,
|
|
98
|
+
"color": s.color,
|
|
99
|
+
}
|
|
100
|
+
for s in self.series
|
|
101
|
+
],
|
|
102
|
+
"value_format": self.value_format.to_dict() if self.value_format else None,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Backwards compatibility alias
|
|
107
|
+
ChartSpec = Chart
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ChartRenderer — abstract base for rendering a Chart to a specific backend.
|
|
3
|
+
|
|
4
|
+
Implement render(chart, **kwargs) to add a new backend (plotly, charts.js, etc.).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
from pyproforma.chart.chart import Chart
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ChartRenderer(ABC):
|
|
13
|
+
"""Base class for rendering a Chart to a specific backend."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def render(self, chart: Chart, **kwargs):
|
|
17
|
+
"""Render the Chart and return the backend's figure/output object."""
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MatplotlibRenderer — renders a Chart to a matplotlib Figure.
|
|
3
|
+
|
|
4
|
+
Called by Chart.show() and Chart.figure(). Supports line, bar, and stacked_bar
|
|
5
|
+
chart types. Applies NumberFormatSpec to y-axis tick labels when set.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pyproforma.chart.renderers.base import ChartRenderer
|
|
11
|
+
from pyproforma.chart.chart import Chart as ChartSpec
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MatplotlibRenderer(ChartRenderer):
|
|
15
|
+
"""Renders a ChartSpec to a matplotlib Figure."""
|
|
16
|
+
|
|
17
|
+
def render(self, spec: ChartSpec, figsize: tuple[float, float] = (10, 6)):
|
|
18
|
+
"""
|
|
19
|
+
Render a ChartSpec and return a matplotlib Figure.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
spec: The chart specification to render.
|
|
23
|
+
figsize: (width, height) in inches. Defaults to (10, 6).
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
matplotlib.figure.Figure
|
|
27
|
+
"""
|
|
28
|
+
import matplotlib.pyplot as plt
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
32
|
+
|
|
33
|
+
if spec.chart_type == "line":
|
|
34
|
+
self._render_line(ax, spec)
|
|
35
|
+
elif spec.chart_type == "bar":
|
|
36
|
+
self._render_bar(ax, spec)
|
|
37
|
+
elif spec.chart_type == "stacked_bar":
|
|
38
|
+
self._render_stacked_bar(ax, spec)
|
|
39
|
+
|
|
40
|
+
self._apply_labels(ax, spec)
|
|
41
|
+
self._apply_y_format(ax, spec)
|
|
42
|
+
|
|
43
|
+
if len(spec.series) > 1:
|
|
44
|
+
ax.legend()
|
|
45
|
+
|
|
46
|
+
ax.grid(True, alpha=0.3)
|
|
47
|
+
plt.tight_layout()
|
|
48
|
+
|
|
49
|
+
return fig
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
# Chart type renderers
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def _render_line(self, ax, spec: ChartSpec) -> None:
|
|
56
|
+
for series in spec.series:
|
|
57
|
+
ax.plot(
|
|
58
|
+
series.x_values,
|
|
59
|
+
series.y_values,
|
|
60
|
+
label=series.label,
|
|
61
|
+
color=series.color,
|
|
62
|
+
marker="o",
|
|
63
|
+
linewidth=2,
|
|
64
|
+
markersize=5,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def _render_bar(self, ax, spec: ChartSpec) -> None:
|
|
68
|
+
import numpy as np
|
|
69
|
+
|
|
70
|
+
n = len(spec.series)
|
|
71
|
+
x = np.arange(len(spec.series[0].x_values))
|
|
72
|
+
width = 0.8 / n
|
|
73
|
+
|
|
74
|
+
for i, series in enumerate(spec.series):
|
|
75
|
+
offset = (i - n / 2 + 0.5) * width
|
|
76
|
+
ax.bar(x + offset, series.y_values, width, label=series.label, color=series.color)
|
|
77
|
+
|
|
78
|
+
ax.set_xticks(x)
|
|
79
|
+
ax.set_xticklabels([str(v) for v in spec.series[0].x_values])
|
|
80
|
+
|
|
81
|
+
def _render_stacked_bar(self, ax, spec: ChartSpec) -> None:
|
|
82
|
+
import numpy as np
|
|
83
|
+
|
|
84
|
+
x = np.arange(len(spec.series[0].x_values))
|
|
85
|
+
bottom = np.zeros(len(spec.series[0].x_values))
|
|
86
|
+
|
|
87
|
+
for series in spec.series:
|
|
88
|
+
y = np.array(series.y_values)
|
|
89
|
+
ax.bar(x, y, bottom=bottom, label=series.label, color=series.color)
|
|
90
|
+
bottom += y
|
|
91
|
+
|
|
92
|
+
ax.set_xticks(x)
|
|
93
|
+
ax.set_xticklabels([str(v) for v in spec.series[0].x_values])
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
# Shared helpers
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def _apply_labels(self, ax, spec: ChartSpec) -> None:
|
|
100
|
+
if spec.title:
|
|
101
|
+
ax.set_title(spec.title)
|
|
102
|
+
if spec.x_label:
|
|
103
|
+
ax.set_xlabel(spec.x_label)
|
|
104
|
+
if spec.y_label:
|
|
105
|
+
ax.set_ylabel(spec.y_label)
|
|
106
|
+
|
|
107
|
+
def _apply_y_format(self, ax, spec: ChartSpec) -> None:
|
|
108
|
+
if spec.value_format is None:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
from matplotlib.ticker import FuncFormatter
|
|
112
|
+
from pyproforma.table.format_value import format_value
|
|
113
|
+
|
|
114
|
+
fmt = spec.value_format
|
|
115
|
+
ax.yaxis.set_major_formatter(FuncFormatter(lambda val, _pos: format_value(val, fmt)))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ChartDef — declarative definition of a chart to build from a model.
|
|
3
|
+
|
|
4
|
+
Used with model.charts.build() to produce a Chart. Holds the line item names,
|
|
5
|
+
chart type, title, and optional per-series colors. Accepts either the dataclass
|
|
6
|
+
form (IDE autocomplete, validation) or a plain dict (JSON-serializable configs).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Union
|
|
11
|
+
|
|
12
|
+
from pyproforma.chart.chart import ChartType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ChartDef:
|
|
17
|
+
"""
|
|
18
|
+
Declarative definition of a chart to build from a model.
|
|
19
|
+
|
|
20
|
+
Used with model.charts.from_template() to produce a ChartSpec.
|
|
21
|
+
Accepts either the dataclass form (for Python code with IDE support)
|
|
22
|
+
or a plain dict (for JSON-serializable configs).
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
>>> ChartDef(names=["revenue", "expenses"])
|
|
26
|
+
>>> ChartDef(names=["revenue"], chart_type="bar", title="Revenue")
|
|
27
|
+
>>> ChartDef(names=["revenue", "expenses"], colors=["#206bc4", "#d63939"])
|
|
28
|
+
|
|
29
|
+
The dict form is equivalent:
|
|
30
|
+
>>> {"names": ["revenue"], "chart_type": "bar", "colors": ["#206bc4"]}
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
names: list[str]
|
|
34
|
+
chart_type: ChartType = "line"
|
|
35
|
+
title: str | None = None
|
|
36
|
+
colors: list[str] | None = None
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_dict(cls, data: dict) -> "ChartDef":
|
|
40
|
+
return cls(
|
|
41
|
+
names=data["names"],
|
|
42
|
+
chart_type=data.get("chart_type", "line"),
|
|
43
|
+
title=data.get("title"),
|
|
44
|
+
colors=data.get("colors"),
|
|
45
|
+
)
|