@heylemon/lemonade 0.1.6 → 0.1.7

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.
@@ -1,53 +1,251 @@
1
- # Spreadsheet Spec Format
1
+ # Financial Model Standards
2
2
 
3
- ## Top Level
3
+ Professional Excel financial models follow industry conventions for colors, formatting, and structure. These standards ensure models are readable, auditable, and suitable for investor presentations.
4
+
5
+ ## Color Coding Reference
6
+
7
+ All financial models use consistent color coding for cell types:
8
+
9
+ | Color | Meaning | Cell Type | RGB (Excel) | Example |
10
+ |---|---|---|---|---|
11
+ | Blue text | Input values | Hardcoded numbers user changes | (0, 0, 255) | Revenue growth rates, unit prices |
12
+ | Black text | Formulas | Calculated values | (0, 0, 0) | SUM totals, margin calculations |
13
+ | Green text | Cross-sheet references | Formulas linking other sheets | (0, 128, 0) | Pulling actuals from Data sheet |
14
+ | Red text | External references | Data from outside sources | (255, 0, 0) | Market data, benchmark rates |
15
+ | Yellow background | Key assumptions | Critical driver cells | Yellow | Growth rate, discount rate, conversion |
16
+
17
+ ## Number Formatting Conventions
18
+
19
+ Apply these formats to match industry standards and improve readability:
20
+
21
+ | Data Type | Format String | Example | Use Case |
22
+ |---|---|---|---|
23
+ | Currency (standard) | `$#,##0` | $45,000 | Individual amounts |
24
+ | Currency (thousands) | `$#,##0,"K"` | $45K | Large amounts, summaries |
25
+ | Currency (millions) | `$#,##0,,"M"` | $1,245M | Very large amounts |
26
+ | Percentage | `0.0%` | 15.2% | Margins, growth rates |
27
+ | Percentage (no decimals) | `0%` | 15% | When precision not needed |
28
+ | Multiples | `0.0x` | 3.2x | Revenue multiples, LTV/CAC |
29
+ | Years (text) | Text format | 2026 | Period headers (never 2,026) |
30
+ | Zero display | `_($* #,##0_);_($* (#,##0);_($* "-"_);_(@_)` | – | Display zeros as dashes |
31
+ | Negatives in parens | `($#,##0)` | ($5,000) | Cash flows, deficits |
32
+
33
+ ## Cell Types by Use Case
34
+
35
+ ### Revenue/Income Statement Model
36
+
37
+ ```
38
+ Row 1: [Metric] [2024A] [2025E] [2026E]
39
+ Row 2: Revenue ($K) [1000] [1500] [2100] <- Blue inputs
40
+ Row 3: Growth Rate — 50% 40% <- Blue inputs + Yellow bg
41
+ Row 4: COGS ($K) [400] [600] [820] <- Formulas (black)
42
+ Row 5: Gross Margin % 40.0% 40.0% 39.0% <- Blue inputs + Yellow bg
43
+ Row 6: OpEx ($K) [300] [350] [420] <- Blue inputs
44
+ Row 7: EBITDA ($K) [300] [550] [860] <- Formulas (black)
45
+ Row 8: EBITDA Margin 30.0% 36.7% 41.0% <- Formulas (black)
46
+ ```
47
+
48
+ ### Cash Flow Model
49
+
50
+ ```
51
+ Row 1: [Item] [Month 1] [Month 2] [Month 3]
52
+ Row 2: Beginning Cash [100,000] [formula] [formula] <- Blue for Month 1, black after
53
+ Row 3: Operating Cash In [50,000] [50,000] [60,000] <- Blue inputs
54
+ Row 4: Operating Cash Out [30,000] [28,000] [32,000] <- Blue inputs
55
+ Row 5: Operating Cash Flow [formula] [formula] [formula] <- Formulas (black)
56
+ Row 6: Capex [10,000] [10,000] [—] <- Blue inputs
57
+ Row 7: Ending Cash [formula] [formula] [formula] <- Formulas (black)
58
+ ```
59
+
60
+ ### 3-Statement Waterfall Model
4
61
 
5
- ```json
6
- {
7
- "brand": {},
8
- "sheets": []
9
- }
10
62
  ```
63
+ [2024A] [2025E] [2026E]
64
+ Income Statement
65
+ Revenue 1000 1500 2100 <- Blue inputs
66
+ COGS -400 -600 -820 <- Formulas (black) or blue
67
+ Operating Exp -300 -350 -420 <- Blue inputs
68
+ EBITDA 300 550 860 <- Formulas (black)
69
+ D&A -50 -50 -50 <- Blue inputs
70
+ EBIT 250 500 810 <- Formulas (black)
71
+ Interest -25 -25 -20 <- Blue inputs or formulas
72
+ PBT 225 475 790 <- Formulas (black)
73
+ Tax (35%) -79 -166 -277 <- Formulas (black)
74
+ Net Income 146 309 513 <- Formulas (black)
11
75
 
12
- ## Sheet Types
76
+ Balance Sheet
77
+ Cash 100 300 [formula] <- Blue or cross-sheet link (green)
78
+ AR [formula] [formula] [formula] <- Formulas based on sales
79
+ Assets [formula] [formula] [formula] <- Formulas (sum)
80
+ AP [formula] [formula] [formula] <- Formulas based on COGS
81
+ Debt 500 400 [formula] <- Blue or calculation
82
+ Equity [formula] [formula] [formula] <- Formulas
13
83
 
14
- ### data
84
+ Cash Flow
85
+ Operating CF [formula] [formula] [formula] <- Formulas from I/S + BS changes
86
+ Investing CF -100 -100 -150 <- Blue inputs (capex)
87
+ Financing CF [formula] [formula] [formula] <- Formulas (debt/equity changes)
88
+ Net Cash Change [formula] [formula] [formula] <- Formulas (sum of above)
89
+ ```
90
+
91
+ ## Assumptions Sheet Structure
92
+
93
+ Always create a dedicated "Assumptions" sheet:
94
+
95
+ ```
96
+ Row 1: [Category] [Value] [Comment]
97
+ Row 2: ========== FINANCIAL DRIVERS ==========
98
+ Row 3: Revenue Growth 50% <- Blue input + yellow bg
99
+ Row 4: Gross Margin 40.0% <- Blue input + yellow bg
100
+ Row 5: Tax Rate 35.0% <- Blue input + yellow bg
101
+ Row 6:
102
+ Row 7: ========== BALANCE SHEET DRIVERS ==========
103
+ Row 8: Days Sales Outstanding 45 <- Blue input + yellow bg
104
+ Row 9: Days Payable 30 <- Blue input + yellow bg
105
+ Row 10: Capex as % of Revenue 5% <- Blue input + yellow bg
106
+ Row 11:
107
+ Row 12: ========== WORKING CAPITAL ==========
108
+ Row 13: AR Days 45 <- Blue input
109
+ Row 14: Inventory Days 30 <- Blue input
110
+ Row 15: AP Days 30 <- Blue input
111
+ ```
112
+
113
+ ## Formula Best Practices
114
+
115
+ ### Hardcoded vs. Referenced
15
116
 
16
- ```json
17
- {
18
- "name": "Sales Data",
19
- "type": "data",
20
- "title": "Q1 2026 Sales Pipeline",
21
- "headers": ["Deal", "Stage", "Value", "Close Date", "Owner"],
22
- "rows": [["Acme", "Negotiation", 45000, "2026-03-15", "Sarah"]],
23
- "freeze_panes": "A2",
24
- "auto_filter": true,
25
- "formulas": [{"cell": "C50", "formula": "=SUM(C2:C49)", "bold": true}]
26
- }
117
+ **WRONG** — Constants baked into formulas:
118
+ ```
119
+ C4 = C2 * 0.40 (hardcoded 40% margin)
120
+ D7 = C7 + C7 * 0.15 (hardcoded 15% tax rate)
121
+ ```
122
+
123
+ **RIGHT** References to assumption cells:
124
+ ```
125
+ C4 = C2 * $B$5 (references cell B5: Gross Margin)
126
+ D7 = C7 + C7 * $B$6 (references cell B6: Tax Rate)
27
127
  ```
28
128
 
29
- ### dashboard
129
+ Use absolute references ($B$5) for constants so they don't shift when copying formulas.
130
+
131
+ ### Cross-Sheet References
132
+
133
+ Format: `='Sheet Name'!Cell`
30
134
 
31
- ```json
32
- {
33
- "name": "Dashboard",
34
- "type": "dashboard",
35
- "title": "Q1 Performance Dashboard",
36
- "kpis": [{"value": "$156K", "label": "Monthly Revenue", "change": "+32%"}],
37
- "sections": [{"title": "Top Deals", "headers": ["Client", "Value"], "rows": [["Enterprise Co", "$120K"]]}]
38
- }
39
135
  ```
136
+ =SUM('P&L'!B7:B100) (Sum from P&L sheet)
137
+ ='Assumptions'!B3 (Pull growth rate from assumptions)
138
+ ```
139
+
140
+ ### Handling Zeros and Blanks
40
141
 
41
- ### financial
142
+ Avoid errors with defensive formulas:
42
143
 
43
- ```json
44
- {
45
- "name": "Financial Model",
46
- "type": "financial",
47
- "headers": ["", "2024A", "2025E", "2026E"],
48
- "rows": [["Revenue", 480, 960, 1920]],
49
- "input_cells": ["B2"],
50
- "assumption_cells": ["C2"],
51
- "formulas": [{"cell": "D2", "formula": "=C2*(1+C3)"}]
52
- }
53
144
  ```
145
+ =IF(B2=0, 0, A2/B2) (Guard division by zero)
146
+ =IF(ISBLANK(B2), 0, B2 * C2) (Handle missing data)
147
+ =IFERROR(A2/B2, 0) (Catch any error)
148
+ ```
149
+
150
+ ## Documentation in Cells
151
+
152
+ Use Excel comments (not cell notes) for formulas needing explanation:
153
+
154
+ ```
155
+ Cell C7 (EBITDA calculation):
156
+ Comment: "EBITDA = Revenue - COGS - OpEx. Based on full-year actuals from Finance system."
157
+
158
+ Cell B5 (Gross Margin assumption):
159
+ Comment: "Historical 40% based on 3-year average. See Data!B:B for backup calculations."
160
+ ```
161
+
162
+ ## Common Financial Model Patterns
163
+
164
+ ### Scenario Analysis
165
+
166
+ Create separate sections or sheets for Base, Bull, Bear cases:
167
+
168
+ ```
169
+ [Base] [Bull] [Bear]
170
+ Revenue Growth 40% 60% 20% <- Blue inputs
171
+ EBITDA Margin 35% 40% 30% <- Blue inputs
172
+ [All metrics below use formulas referencing above]
173
+ ```
174
+
175
+ ### Waterfall from Revenue to Free Cash Flow
176
+
177
+ ```
178
+ Revenue <- Input or formula from sales forecast
179
+ - COGS <- Input or % of revenue
180
+ = Gross Profit <- Formula
181
+ - Operating Expenses <- Input or calculated
182
+ = EBITDA <- Formula
183
+ - D&A <- Input or calculated
184
+ = EBIT <- Formula
185
+ - Interest <- Input or calculated
186
+ - Taxes <- Formula (EBIT * tax rate)
187
+ = Net Income <- Formula
188
+ + D&A (add back) <- Formula
189
+ - Capex <- Input
190
+ - Working Capital Change <- Formula
191
+ = Free Cash Flow <- Formula
192
+ ```
193
+
194
+ ## Checkpoints for Model Validation
195
+
196
+ Before sharing a financial model:
197
+
198
+ 1. **Formula integrity**: Run recalc.py and verify zero errors
199
+ 2. **Color consistency**: All inputs blue, all formulas black, assumptions yellow
200
+ 3. **Documentation**: Complex formulas have comments, sources cited
201
+ 4. **Formatting**: Currency formatted, percentages 0.0%, years as text
202
+ 5. **Cross-checks**: Income statement ties to cash flow, balance sheet balances
203
+ 6. **Scenario testing**: Change an assumption, verify all dependents update
204
+ 7. **Reasonableness**: Review outputs for logic errors (negative revenue, etc.)
205
+
206
+ ## Example: Simple 3-Year Revenue Model
207
+
208
+ ```python
209
+ from openpyxl import Workbook
210
+ from openpyxl.styles import Font, PatternFill
211
+
212
+ wb = Workbook()
213
+ ws = wb.active
214
+ ws.title = "P&L"
215
+
216
+ # Headers
217
+ ws['A1'] = "Metric"
218
+ ws['B1'] = "2024A"
219
+ ws['C1'] = "2025E"
220
+ ws['D1'] = "2026E"
221
+
222
+ # Revenue (Blue inputs for projections)
223
+ ws['A2'] = "Revenue ($K)"
224
+ ws['B2'] = 1000 # Actual
225
+ ws['B2'].font = Font(color="000000") # Black for actual
226
+ ws['C2'].value = "=B2*(1+$B$5)" # Formula
227
+ ws['C2'].font = Font(color="000000")
228
+ ws['D2'].value = "=C2*(1+$B$6)"
229
+ ws['D2'].font = Font(color="000000")
230
+
231
+ # Growth Rate (Blue inputs + Yellow assumption)
232
+ ws['A5'] = "2025 Growth Rate"
233
+ ws['B5'] = 0.50
234
+ ws['B5'].font = Font(color="0000FF")
235
+ ws['B5'].fill = PatternFill(start_color="FFFF00", fill_type="solid")
236
+ ws['B5'].number_format = "0.0%"
237
+
238
+ ws['A6'] = "2026 Growth Rate"
239
+ ws['B6'] = 0.40
240
+ ws['B6'].font = Font(color="0000FF")
241
+ ws['B6'].fill = PatternFill(start_color="FFFF00", fill_type="solid")
242
+ ws['B6'].number_format = "0.0%"
243
+
244
+ # Format revenue cells
245
+ for col in ['B', 'C', 'D']:
246
+ ws[f'{col}2'].number_format = "$#,##0"
247
+
248
+ wb.save('p_and_l.xlsx')
249
+ ```
250
+
251
+ Then run: `python scripts/recalc.py p_and_l.xlsx`
@@ -1,53 +1,63 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
3
  Pro Sheets — Spreadsheet Creation Engine
4
+ Takes a JSON spec and produces a professionally formatted .xlsx file.
5
+
6
+ Usage: python create_xlsx.py spec.json output.xlsx
7
+
8
+ JSON spec format: see references/spec-format.md
4
9
  """
5
10
 
6
11
  import json
7
12
  import sys
13
+ import os
14
+ from datetime import datetime
8
15
 
9
16
  try:
10
17
  import openpyxl
11
- from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
18
+ from openpyxl.styles import Font, PatternFill, Alignment, Border, Side, numbers
12
19
  from openpyxl.utils import get_column_letter
13
20
  except ImportError:
14
21
  print("ERROR: openpyxl not installed. Run: pip install openpyxl --break-system-packages")
15
22
  sys.exit(1)
16
23
 
24
+
25
+ # ═══════════════════════════════════════════
26
+ # DEFAULT BRAND
27
+ # ═══════════════════════════════════════════
17
28
  DEFAULT_BRAND = {
18
- "primary": "1B3A5C",
19
- "accent": "2E86AB",
20
- "light_bg": "F8FAFC",
21
- "text_dark": "2C3E50",
22
- "text_light": "FFFFFF",
23
- "border": "D5D8DC",
24
- "positive": "27AE60",
25
- "negative": "E74C3C",
26
- "highlight": "FFF8E1",
29
+ "primary": "1B3A5C", # Dark navy headers
30
+ "accent": "2E86AB", # Teal accents
31
+ "light_bg": "F8FAFC", # Alternating row
32
+ "text_dark": "2C3E50", # Body text
33
+ "text_light": "FFFFFF", # White on dark
34
+ "border": "D5D8DC", # Light gray borders
35
+ "positive": "27AE60", # Green for positive
36
+ "negative": "E74C3C", # Red for negative
37
+ "highlight": "FFF8E1", # Gold highlight
27
38
  "font": "Arial",
28
39
  }
29
40
 
41
+ # ═══════════════════════════════════════════
42
+ # STYLE HELPERS
43
+ # ═══════════════════════════════════════════
30
44
 
31
45
  def hex_color(c):
32
- return c.lstrip("#")
33
-
46
+ return c.lstrip('#')
34
47
 
35
48
  def make_border(color="D5D8DC"):
36
- side = Side(style="thin", color=hex_color(color))
49
+ side = Side(style='thin', color=hex_color(color))
37
50
  return Border(left=side, right=side, top=side, bottom=side)
38
51
 
39
-
40
52
  def header_font(brand, size=11):
41
53
  return Font(bold=True, size=size, color=hex_color(brand["text_light"]), name=brand["font"])
42
54
 
43
-
44
55
  def header_fill(brand):
45
56
  return PatternFill("solid", fgColor=hex_color(brand["primary"]))
46
57
 
47
-
48
58
  def body_font(brand, size=11, bold=False, color=None):
49
- return Font(size=size, name=brand["font"], bold=bold, color=hex_color(color or brand["text_dark"]))
50
-
59
+ return Font(size=size, name=brand["font"], bold=bold,
60
+ color=hex_color(color or brand["text_dark"]))
51
61
 
52
62
  def alt_fill(brand, row_idx):
53
63
  if row_idx % 2 == 0:
@@ -56,6 +66,8 @@ def alt_fill(brand, row_idx):
56
66
 
57
67
 
58
68
  class ProSheetBuilder:
69
+ """Builds professional spreadsheets from a JSON spec."""
70
+
59
71
  def __init__(self, spec):
60
72
  self.spec = spec
61
73
  self.brand = {**DEFAULT_BRAND, **(spec.get("brand", {}))}
@@ -74,6 +86,7 @@ class ProSheetBuilder:
74
86
  ws.title = sheet_spec.get("name", "Sheet1")
75
87
  else:
76
88
  ws = self.wb.create_sheet(sheet_spec.get("name", f"Sheet{si+1}"))
89
+
77
90
  self._build_sheet(ws, sheet_spec)
78
91
 
79
92
  self.wb.save(output_path)
@@ -81,6 +94,7 @@ class ProSheetBuilder:
81
94
 
82
95
  def _build_sheet(self, ws, sheet_spec):
83
96
  sheet_type = sheet_spec.get("type", "data")
97
+
84
98
  if sheet_type == "dashboard":
85
99
  self._build_dashboard(ws, sheet_spec)
86
100
  elif sheet_type == "financial":
@@ -88,34 +102,42 @@ class ProSheetBuilder:
88
102
  else:
89
103
  self._build_data_sheet(ws, sheet_spec)
90
104
 
105
+ # Freeze panes
91
106
  freeze = sheet_spec.get("freeze_panes")
92
107
  if freeze:
93
108
  ws.freeze_panes = freeze
109
+
110
+ # Auto-filter
94
111
  if sheet_spec.get("auto_filter", False) and ws.max_row > 1:
95
112
  ws.auto_filter.ref = f"A1:{get_column_letter(ws.max_column)}{ws.max_row}"
96
113
 
97
114
  def _build_data_sheet(self, ws, sheet_spec):
115
+ """Standard data table with headers and rows."""
98
116
  headers = sheet_spec.get("headers", [])
99
117
  rows = sheet_spec.get("rows", [])
100
118
  col_widths = sheet_spec.get("col_widths", [])
101
119
  title = sheet_spec.get("title")
102
120
  start_row = 1
103
121
 
122
+ # Title row
104
123
  if title:
105
124
  ws.cell(row=1, column=1, value=title).font = Font(
106
- bold=True, size=16, color=hex_color(self.brand["text_dark"]), name=self.brand["font"]
125
+ bold=True, size=16, color=hex_color(self.brand["text_dark"]),
126
+ name=self.brand["font"]
107
127
  )
108
128
  ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=max(len(headers), 1))
109
129
  start_row = 3
110
130
 
131
+ # Headers
111
132
  if headers:
112
133
  for ci, h in enumerate(headers, 1):
113
134
  cell = ws.cell(row=start_row, column=ci, value=h)
114
135
  cell.font = header_font(self.brand)
115
136
  cell.fill = header_fill(self.brand)
116
- cell.alignment = Alignment(horizontal="center", wrap_text=True)
137
+ cell.alignment = Alignment(horizontal='center', wrap_text=True)
117
138
  cell.border = self.border
118
139
 
140
+ # Data rows
119
141
  for ri, row_data in enumerate(rows):
120
142
  r = start_row + 1 + ri
121
143
  for ci, val in enumerate(row_data, 1):
@@ -123,99 +145,153 @@ class ProSheetBuilder:
123
145
  cell.font = body_font(self.brand)
124
146
  cell.fill = alt_fill(self.brand, ri)
125
147
  cell.border = self.border
126
- cell.alignment = Alignment(wrap_text=True, vertical="top")
148
+ cell.alignment = Alignment(wrap_text=True, vertical='top')
149
+
150
+ # Auto-detect numeric alignment
127
151
  if isinstance(val, (int, float)):
128
- cell.alignment = Alignment(horizontal="right", vertical="top")
152
+ cell.alignment = Alignment(horizontal='right', vertical='top')
129
153
  if isinstance(val, float) and abs(val) < 1:
130
- cell.number_format = "0.0%"
154
+ cell.number_format = '0.0%'
131
155
  elif isinstance(val, float):
132
- cell.number_format = "#,##0.00"
156
+ cell.number_format = '#,##0.00'
133
157
  elif isinstance(val, int) and val > 999:
134
- cell.number_format = "#,##0"
158
+ cell.number_format = '#,##0'
135
159
 
160
+ # Column widths
136
161
  if col_widths:
137
162
  for ci, w in enumerate(col_widths, 1):
138
163
  ws.column_dimensions[get_column_letter(ci)].width = w
139
164
  else:
165
+ # Auto-size based on header length
140
166
  for ci, h in enumerate(headers, 1):
141
167
  ws.column_dimensions[get_column_letter(ci)].width = max(len(str(h)) + 4, 12)
142
168
 
169
+ # Formulas
143
170
  for formula_spec in sheet_spec.get("formulas", []):
144
- cell = ws[formula_spec["cell"]]
145
- cell.value = formula_spec["formula"]
171
+ cell_ref = formula_spec["cell"]
172
+ formula = formula_spec["formula"]
173
+ cell = ws[cell_ref]
174
+ cell.value = formula
146
175
  if formula_spec.get("bold", False):
147
176
  cell.font = body_font(self.brand, bold=True)
148
177
  cell.border = self.border
149
178
 
150
179
  def _build_dashboard(self, ws, sheet_spec):
180
+ """Dashboard with KPI cards and summary tables."""
151
181
  title = sheet_spec.get("title", "Dashboard")
152
182
  kpis = sheet_spec.get("kpis", [])
153
183
  sections = sheet_spec.get("sections", [])
154
184
 
185
+ # Title
155
186
  ws.cell(row=1, column=1, value=title).font = Font(
156
- bold=True, size=18, color=hex_color(self.brand["text_dark"]), name=self.brand["font"]
187
+ bold=True, size=18, color=hex_color(self.brand["text_dark"]),
188
+ name=self.brand["font"]
157
189
  )
158
190
  ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=max(len(kpis) * 2, 6))
159
191
 
192
+ # KPI row
160
193
  if kpis:
161
194
  r = 3
162
195
  for ki, kpi in enumerate(kpis):
163
196
  col = ki * 2 + 1
164
- v = ws.cell(row=r, column=col, value=kpi.get("value", ""))
165
- v.font = Font(bold=True, size=22, name=self.brand["font"], color=hex_color(self.brand["accent"]))
166
- v.alignment = Alignment(horizontal="center")
197
+ # Value
198
+ val_cell = ws.cell(row=r, column=col, value=kpi.get("value", ""))
199
+ val_cell.font = Font(bold=True, size=22, name=self.brand["font"],
200
+ color=hex_color(self.brand["accent"]))
201
+ val_cell.alignment = Alignment(horizontal='center')
167
202
  ws.merge_cells(start_row=r, start_column=col, end_row=r, end_column=col + 1)
168
203
 
169
- l = ws.cell(row=r + 1, column=col, value=kpi.get("label", ""))
170
- l.font = Font(size=9, color="7F8C8D", name=self.brand["font"])
171
- l.alignment = Alignment(horizontal="center")
204
+ # Label
205
+ lbl_cell = ws.cell(row=r + 1, column=col, value=kpi.get("label", ""))
206
+ lbl_cell.font = Font(size=9, color="7F8C8D", name=self.brand["font"])
207
+ lbl_cell.alignment = Alignment(horizontal='center')
172
208
  ws.merge_cells(start_row=r + 1, start_column=col, end_row=r + 1, end_column=col + 1)
173
209
 
210
+ # Change indicator
211
+ change = kpi.get("change", "")
212
+ if change:
213
+ chg_cell = ws.cell(row=r + 2, column=col, value=change)
214
+ is_positive = change.startswith("+") or change.startswith("↑")
215
+ chg_cell.font = Font(size=9, name=self.brand["font"], bold=True,
216
+ color=hex_color(self.brand["positive"] if is_positive else self.brand["negative"]))
217
+ chg_cell.alignment = Alignment(horizontal='center')
218
+ ws.merge_cells(start_row=r + 2, start_column=col, end_row=r + 2, end_column=col + 1)
219
+
220
+ # KPI card border
174
221
  for dr in range(3):
175
222
  for dc in range(2):
176
- c = ws.cell(row=r + dr, column=col + dc)
177
- c.fill = PatternFill("solid", fgColor=hex_color(self.brand["highlight"]))
178
- c.border = self.border
223
+ cell = ws.cell(row=r + dr, column=col + dc)
224
+ cell.fill = PatternFill("solid", fgColor=hex_color(self.brand["highlight"]))
225
+ cell.border = self.border
179
226
 
227
+ ws.column_dimensions[get_column_letter(col)].width = 14
228
+ ws.column_dimensions[get_column_letter(col + 1)].width = 14
229
+
230
+ # Sections (mini tables below KPIs)
180
231
  current_row = 8 if kpis else 3
181
232
  for section in sections:
182
233
  ws.cell(row=current_row, column=1, value=section.get("title", "")).font = Font(
183
- bold=True, size=13, color=hex_color(self.brand["text_dark"]), name=self.brand["font"]
234
+ bold=True, size=13, color=hex_color(self.brand["text_dark"]),
235
+ name=self.brand["font"]
184
236
  )
185
237
  current_row += 1
238
+
186
239
  headers = section.get("headers", [])
187
240
  rows = section.get("rows", [])
188
241
  for ci, h in enumerate(headers, 1):
189
- c = ws.cell(row=current_row, column=ci, value=h)
190
- c.font = header_font(self.brand, size=10)
191
- c.fill = header_fill(self.brand)
192
- c.border = self.border
193
- c.alignment = Alignment(horizontal="center")
242
+ cell = ws.cell(row=current_row, column=ci, value=h)
243
+ cell.font = header_font(self.brand, size=10)
244
+ cell.fill = header_fill(self.brand)
245
+ cell.border = self.border
246
+ cell.alignment = Alignment(horizontal='center')
247
+ ws.column_dimensions[get_column_letter(ci)].width = max(len(str(h)) + 4, 14)
248
+
194
249
  for ri, row_data in enumerate(rows):
195
250
  r = current_row + 1 + ri
196
251
  for ci, val in enumerate(row_data, 1):
197
- c = ws.cell(row=r, column=ci, value=val)
198
- c.font = body_font(self.brand, size=10)
199
- c.fill = alt_fill(self.brand, ri)
200
- c.border = self.border
252
+ cell = ws.cell(row=r, column=ci, value=val)
253
+ cell.font = body_font(self.brand, size=10)
254
+ cell.fill = alt_fill(self.brand, ri)
255
+ cell.border = self.border
256
+
201
257
  current_row += len(rows) + 3
202
258
 
203
259
  def _build_financial(self, ws, sheet_spec):
260
+ """Financial model with blue inputs, black formulas, yellow assumptions."""
204
261
  self._build_data_sheet(ws, sheet_spec)
205
- for ref in sheet_spec.get("input_cells", []):
206
- ws[ref].font = Font(size=11, name=self.brand["font"], color="0000FF")
207
- for ref in sheet_spec.get("assumption_cells", []):
208
- ws[ref].fill = PatternFill("solid", fgColor="FFFF00")
262
+
263
+ # Apply financial color coding
264
+ input_cells = sheet_spec.get("input_cells", [])
265
+ assumption_cells = sheet_spec.get("assumption_cells", [])
266
+
267
+ for ref in input_cells:
268
+ cell = ws[ref]
269
+ cell.font = Font(size=11, name=self.brand["font"], color="0000FF")
270
+
271
+ for ref in assumption_cells:
272
+ cell = ws[ref]
273
+ cell.fill = PatternFill("solid", fgColor="FFFF00")
209
274
 
210
275
 
211
276
  def main():
212
277
  if len(sys.argv) < 3:
213
278
  print("Usage: python create_xlsx.py spec.json output.xlsx")
214
279
  sys.exit(1)
215
- with open(sys.argv[1], "r", encoding="utf-8") as f:
280
+
281
+ spec_path = sys.argv[1]
282
+ output_path = sys.argv[2]
283
+
284
+ with open(spec_path, 'r') as f:
216
285
  spec = json.load(f)
217
- ProSheetBuilder(spec).build(sys.argv[2])
218
- print(f"Spreadsheet created: {sys.argv[2]}")
286
+
287
+ builder = ProSheetBuilder(spec)
288
+ builder.build(output_path)
289
+ print(f"Spreadsheet created: {output_path}")
290
+ print(f" Sheets: {len(spec.get('sheets', []))}")
291
+ for s in spec.get("sheets", []):
292
+ rows = len(s.get("rows", []))
293
+ kpis = len(s.get("kpis", []))
294
+ print(f" {s.get('name', 'Sheet')}: {rows} rows" + (f", {kpis} KPIs" if kpis else ""))
219
295
 
220
296
 
221
297
  if __name__ == "__main__":