Shivam4096 commited on
Commit
ad38045
Β·
verified Β·
1 Parent(s): 3e3f4bc

Upload folder using huggingface_hub

Browse files
builders/build_excel.py CHANGED
@@ -417,6 +417,10 @@ def _dcf_sheet(wb, data):
417
  debt = debt_history[-1] if debt_history else 0
418
 
419
  # Inputs
 
 
 
 
420
  r = 4
421
  _sec(ws, r, "Model Inputs (Editable - Yellow Cells)", 2); r += 1
422
 
@@ -424,7 +428,7 @@ def _dcf_sheet(wb, data):
424
  ws.cell(row=r, column=2, value=latest_fcf).font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; ws.cell(row=r, column=2).fill = PatternFill("solid", fgColor="FFFFE0"); r += 1
425
 
426
  ws.cell(row=r, column=1, value="WACC (Discount Rate)").font = LBL_FONT
427
- ws.cell(row=r, column=2, value=0.10).font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_PCT; ws.cell(row=r, column=2).fill = PatternFill("solid", fgColor="FFFFE0"); r += 1
428
 
429
  ws.cell(row=r, column=1, value="Terminal Growth Rate").font = LBL_FONT
430
  ws.cell(row=r, column=2, value=0.025).font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_PCT; ws.cell(row=r, column=2).fill = PatternFill("solid", fgColor="FFFFE0"); r += 1
@@ -494,8 +498,11 @@ def _dcf_sheet(wb, data):
494
  ws.cell(row=r, column=1, value="Implied Equity Value").font = LBL_FONT
495
  ws.cell(row=r, column=2, value="=B23+B8-B9").font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; r += 1
496
 
497
- ws.cell(row=r, column=1, value="Implied Share Price").font = Font(name="Calibri", size=12, bold=True, color="1F4E78")
498
- ws.cell(row=r, column=2, value="=B24/B10").font = Font(name="Calibri", size=12, bold=True); ws.cell(row=r, column=2).number_format = FMT_DEC; ws.cell(row=r, column=2).fill = PatternFill("solid", fgColor="DDEBF7")
 
 
 
499
 
500
  _widths(ws, [30, 20, 20, 20, 20, 20])
501
 
 
417
  debt = debt_history[-1] if debt_history else 0
418
 
419
  # Inputs
420
+ val_snap = data.get("valuation_snapshot", {})
421
+ wacc_est = val_snap.get("wacc_estimate", 0.10)
422
+ target_price = val_snap.get("target_mean_price", "N/A")
423
+
424
  r = 4
425
  _sec(ws, r, "Model Inputs (Editable - Yellow Cells)", 2); r += 1
426
 
 
428
  ws.cell(row=r, column=2, value=latest_fcf).font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; ws.cell(row=r, column=2).fill = PatternFill("solid", fgColor="FFFFE0"); r += 1
429
 
430
  ws.cell(row=r, column=1, value="WACC (Discount Rate)").font = LBL_FONT
431
+ ws.cell(row=r, column=2, value=wacc_est).font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_PCT; ws.cell(row=r, column=2).fill = PatternFill("solid", fgColor="FFFFE0"); r += 1
432
 
433
  ws.cell(row=r, column=1, value="Terminal Growth Rate").font = LBL_FONT
434
  ws.cell(row=r, column=2, value=0.025).font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_PCT; ws.cell(row=r, column=2).fill = PatternFill("solid", fgColor="FFFFE0"); r += 1
 
498
  ws.cell(row=r, column=1, value="Implied Equity Value").font = LBL_FONT
499
  ws.cell(row=r, column=2, value="=B23+B8-B9").font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; r += 1
500
 
501
+ ws.cell(row=r, column=1, value="Implied Share Price").font = Font(name="Calibri", size=12, bold=True)
502
+ ws.cell(row=r, column=2, value="=B24/B10").font = Font(name="Calibri", size=12, bold=True); ws.cell(row=r, column=2).number_format = FMT_MONEY; r += 1
503
+
504
+ ws.cell(row=r, column=1, value="Analyst Mean Target Price").font = LBL_FONT
505
+ ws.cell(row=r, column=2, value=target_price).font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; ws.cell(row=r, column=2).fill = PatternFill("solid", fgColor="DDEBF7"); r += 1
506
 
507
  _widths(ws, [30, 20, 20, 20, 20, 20])
508
 
builders/build_pptx.py CHANGED
@@ -1,449 +1,439 @@
1
- """
2
- builders/build_pptx.py β€” PowerPoint presentation builder.
3
-
4
- Generates a widescreen (16:9) PPTX presentation summarizing the financial analysis
5
- and AI narrative, strictly matching user-defined theme, colors, and layout structure.
6
- """
7
- import os
8
- import sys
9
- import datetime
10
- from pathlib import Path
11
- from typing import Dict, List
12
-
13
- # import python-pptx classes
14
- from pptx import Presentation
15
- from pptx.util import Inches, Pt
16
- from pptx.dml.color import RGBColor
17
- from pptx.enum.text import PP_ALIGN
18
- from pptx.enum.shapes import MSO_SHAPE
19
- from pptx.enum.shapes import MSO_CONNECTOR
20
-
21
- # Color Palette defined in User Rules
22
- COLOR_DARK_TEXT = RGBColor(0, 0, 0) # 000000 (Black)
23
- COLOR_LIGHT_BG = RGBColor(255, 255, 255) # FFFFFF (White)
24
- COLOR_DARK_GREEN = RGBColor(0, 132, 0) # 008400 (Green Bar 1)
25
- COLOR_LIGHT_GREEN = RGBColor(149, 203, 137) # 95CB89 (Green Bar 2)
26
- COLOR_GRAY_LINE = RGBColor(0, 0, 0) # 000000 (Black bottom line)
27
- COLOR_DARK_SLATE = RGBColor(69, 95, 81) # 455F51 (Dark slate green)
28
- COLOR_LIGHT_GRAY = RGBColor(227, 222, 209) # E3DED1 (Light warm gray/beige)
29
- COLOR_ACCENT = RGBColor(84, 158, 57) # 549E39 (Accent green)
30
-
31
- FONT_MAJOR = "Helvetica Neue Bold"
32
- FONT_MINOR = "Helvetica Neue Light"
33
- FONT_MAJOR_FALLBACK = "Arial"
34
- FONT_MINOR_FALLBACK = "Arial"
35
-
36
- def _apply_theme_decorations(slide, prs):
37
- """
38
- Applies the layout rectangles and bottom line strictly defined in the
39
- Slide Master Layout.
40
- """
41
- # 1. Decorative top-left dark green bar
42
- # rect: x: 0.15, y: 0.0, w: 0.36, h: 1.16
43
- rect1 = slide.shapes.add_shape(
44
- MSO_SHAPE.RECTANGLE,
45
- Inches(0.15), Inches(0.0), Inches(0.36), Inches(1.16)
46
- )
47
- rect1.fill.solid()
48
- rect1.fill.fore_color.rgb = COLOR_DARK_GREEN
49
- rect1.line.fill.background() # No border line
50
-
51
- # 2. Decorative top-left light green bar
52
- # rect: x: 0.55, y: 0.0, w: 0.11, h: 1.39
53
- rect2 = slide.shapes.add_shape(
54
- MSO_SHAPE.RECTANGLE,
55
- Inches(0.55), Inches(0.0), Inches(0.11), Inches(1.39)
56
- )
57
- rect2.fill.solid()
58
- rect2.fill.fore_color.rgb = COLOR_LIGHT_GREEN
59
- rect2.line.fill.background() # No border line
60
-
61
- # 3. Decorative bottom line
62
- # line: x: 0.67, y: 6.83, w: 12.0, h: 0.0
63
- connector = slide.shapes.add_connector(
64
- MSO_CONNECTOR.STRAIGHT,
65
- Inches(0.67), Inches(6.83), Inches(12.67), Inches(6.83)
66
- )
67
- connector.line.color.rgb = COLOR_GRAY_LINE
68
- connector.line.width = Pt(1)
69
-
70
- def _add_slide_title(slide, text: str):
71
- """
72
- Adds the Slide Title using Layout 7 Title configurations:
73
- placeholder: x: 0.81, y: 0.29, w: 11.74, h: 0.38, align: left, valign: top
74
- """
75
- tx_box = slide.shapes.add_textbox(
76
- Inches(0.81), Inches(0.29), Inches(11.74), Inches(0.38)
77
- )
78
- tf = tx_box.text_frame
79
- tf.word_wrap = True
80
- tf.margin_top = 0
81
- tf.margin_bottom = 0
82
- tf.margin_left = 0
83
- tf.margin_right = 0
84
-
85
- p = tf.paragraphs[0]
86
- p.text = text
87
- p.font.name = FONT_MAJOR
88
- p.font.size = Pt(20)
89
- p.font.bold = True
90
- p.font.color.rgb = COLOR_DARK_TEXT
91
- p.alignment = PP_ALIGN.LEFT
92
-
93
- def _add_bullet_points(slide, bullets: List[str], top_inch=1.56, height_inch=5.14):
94
- """
95
- Adds bullet points body text block using Master 2 Body:
96
- placeholder: x: 0.67, y: top, w: 12.0, h: height
97
- """
98
- tx_box = slide.shapes.add_textbox(
99
- Inches(0.67), Inches(top_inch), Inches(12.0), Inches(height_inch)
100
- )
101
- tf = tx_box.text_frame
102
- tf.word_wrap = True
103
- tf.margin_top = 0
104
- tf.margin_bottom = 0
105
- tf.margin_left = 0
106
- tf.margin_right = 0
107
-
108
- for i, b in enumerate(bullets):
109
- p = tf.add_paragraph() if i > 0 else tf.paragraphs[0]
110
- p.text = "β€’ " + b
111
- p.font.name = FONT_MINOR
112
- p.font.size = Pt(15)
113
- p.font.color.rgb = COLOR_DARK_TEXT
114
- p.space_after = Pt(8)
115
- p.line_spacing = 1.2
116
-
117
- def _style_table_cell(cell, text: str, font_name=FONT_MINOR, font_size=11, bold=False, text_color=COLOR_DARK_TEXT, bg_color=None, align=PP_ALIGN.CENTER):
118
- """Styles cell texts and background fills."""
119
- cell.text = text
120
- p = cell.text_frame.paragraphs[0]
121
- p.alignment = align
122
- p.font.name = font_name
123
- p.font.size = Pt(font_size)
124
- p.font.bold = bold
125
- p.font.color.rgb = text_color
126
-
127
- if bg_color:
128
- cell.fill.solid()
129
- cell.fill.fore_color.rgb = bg_color
130
-
131
- def build_presentation(data: Dict, output_path: str):
132
- """
133
- Assembles a widescreen PowerPoint report from scraped financial data and AI models output.
134
- """
135
- prs = Presentation()
136
-
137
- # Strictly configure widescreen 16:9 aspect ratio
138
- prs.slide_width = Inches(13.333)
139
- prs.slide_height = Inches(7.5)
140
-
141
- blank_layout = prs.slide_layouts[6] # Blank slide template
142
-
143
- company_name = data.get("company", {}).get("name", "Unknown Company")
144
- ticker = data.get("company", {}).get("ticker", "N/A")
145
- industry = data.get("company", {}).get("industry", "N/A")
146
- sector = data.get("company", {}).get("sector", "N/A")
147
- date_str = data.get("company", {}).get("report_date", datetime.date.today().isoformat())
148
- years = data.get("years", [])
149
-
150
- # ==========================================
151
- # SLIDE 1: Title Slide
152
- # ==========================================
153
- slide_title = prs.slides.add_slide(blank_layout)
154
- _apply_theme_decorations(slide_title, prs)
155
-
156
- # Title Text Frame
157
- title_box = slide_title.shapes.add_textbox(
158
- Inches(1.0), Inches(2.2), Inches(11.3), Inches(2.0)
159
- )
160
- tf = title_box.text_frame
161
- tf.word_wrap = True
162
- p1 = tf.paragraphs[0]
163
- p1.text = company_name
164
- p1.font.name = FONT_MAJOR
165
- p1.font.size = Pt(40)
166
- p1.font.bold = True
167
- p1.font.color.rgb = COLOR_DARK_TEXT
168
-
169
- p2 = tf.add_paragraph()
170
- p2.text = f"Financial Analysis & Investment Insights ({ticker})"
171
- p2.font.name = FONT_MINOR
172
- p2.font.size = Pt(22)
173
- p2.font.color.rgb = COLOR_DARK_SLATE
174
- p2.space_before = Pt(8)
175
-
176
- # Subtitle Info (Date / Authors)
177
- sub_box = slide_title.shapes.add_textbox(
178
- Inches(1.0), Inches(5.0), Inches(11.3), Inches(1.2)
179
- )
180
- tf_sub = sub_box.text_frame
181
- p_sub = tf_sub.paragraphs[0]
182
- p_sub.text = f"Report Date: {date_str} | Sector: {sector} | Industry: {industry}"
183
- p_sub.font.name = FONT_MINOR
184
- p_sub.font.size = Pt(13)
185
- p_sub.font.color.rgb = COLOR_DARK_SLATE
186
-
187
- p_sub2 = tf_sub.add_paragraph()
188
- p_sub2.text = "Disclaimer: This report is for informational purposes only and does not constitute investment advice."
189
- p_sub2.font.name = FONT_MINOR
190
- p_sub2.font.size = Pt(11)
191
- p_sub2.font.color.rgb = COLOR_DARK_SLATE
192
- p_sub2.space_before = Pt(6)
193
-
194
- # ==========================================
195
- # SLIDE 2: Executive Summary
196
- # ==========================================
197
- exec_summary_text = data.get("executive_summary", "")
198
- if exec_summary_text and len(exec_summary_text) > 50:
199
- slide_exec = prs.slides.add_slide(blank_layout)
200
- _apply_theme_decorations(slide_exec, prs)
201
- _add_slide_title(slide_exec, "Executive Summary")
202
-
203
- # Clean up text and divide into bullet blocks
204
- paragraphs = [p.strip() for p in exec_summary_text.split('\n') if p.strip()]
205
- _add_bullet_points(slide_exec, paragraphs[:5])
206
-
207
- # ==========================================
208
- # SLIDE 3: Key Financial Highlights Table
209
- # ==========================================
210
- if years:
211
- slide_metrics = prs.slides.add_slide(blank_layout)
212
- _apply_theme_decorations(slide_metrics, prs)
213
- _add_slide_title(slide_metrics, "Key Financial Performance")
214
-
215
- # Build Grid Table
216
- cols = len(years) + 1
217
- rows = 6
218
- table_shape = slide_metrics.shapes.add_table(
219
- rows, cols, Inches(0.8), Inches(1.6), Inches(11.7), Inches(3.8)
220
- )
221
- table = table_shape.table
222
-
223
- # Column Headers
224
- _style_table_cell(table.cell(0, 0), "Financial Metric", FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE, PP_ALIGN.LEFT)
225
- for idx, y in enumerate(years):
226
- _style_table_cell(table.cell(0, idx+1), str(y), FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE)
227
-
228
- metrics = [
229
- ("Revenue", "revenue", "income_statement", 1.0),
230
- ("Gross Profit", "gross_profit", "income_statement", 1.0),
231
- ("Operating Income (EBIT)", "operating_income", "income_statement", 1.0),
232
- ("Net Income", "net_income", "income_statement", 1.0),
233
- ("Operating Cash Flow (OCF)", "cash_from_operations", "cash_flow", 1.0)
234
- ]
235
-
236
- currency = data.get("company", {}).get("currency", "USD")
237
- units = data.get("company", {}).get("units", "millions")
238
-
239
- for r_idx, (m_label, dict_key, sheet_name, scale) in enumerate(metrics):
240
- curr_row = r_idx + 1
241
- bg_color = COLOR_LIGHT_GRAY if curr_row % 2 == 1 else COLOR_LIGHT_BG
242
-
243
- # Label cell
244
- _style_table_cell(table.cell(curr_row, 0), f"{m_label} ({currency} {units.capitalize()})", FONT_MAJOR, 11, False, COLOR_DARK_TEXT, bg_color, PP_ALIGN.LEFT)
245
-
246
- # Values cells
247
- sheet_data = data.get(sheet_name, {})
248
- val_list = sheet_data.get(dict_key, [])
249
- for c_idx, y in enumerate(years):
250
- val_str = "N/A"
251
- if c_idx < len(val_list) and val_list[c_idx] is not None:
252
- val_str = f"{val_list[c_idx]:,.1f}"
253
- _style_table_cell(table.cell(curr_row, c_idx+1), val_str, FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color)
254
-
255
- # Brief Narrative summary underneath table
256
- rev_commentary = data.get("trend_analysis", {}).get("revenue_and_profitability", "")
257
- if rev_commentary:
258
- comment_box = slide_metrics.shapes.add_textbox(Inches(0.8), Inches(5.6), Inches(11.7), Inches(1.1))
259
- tf_c = comment_box.text_frame
260
- tf_c.word_wrap = True
261
- p_c = tf_c.paragraphs[0]
262
- p_c.text = "Key Takeaway: " + (rev_commentary[:260] + "..." if len(rev_commentary) > 260 else rev_commentary)
263
- p_c.font.name = FONT_MINOR
264
- p_c.font.size = Pt(12)
265
- p_c.font.italic = True
266
- p_c.font.color.rgb = COLOR_DARK_SLATE
267
-
268
- # ==========================================
269
- # SLIDE 4: Dupont ROE Decomposition
270
- # ==========================================
271
- dp_data = data.get("dupont", {})
272
- if dp_data and "years" in dp_data:
273
- slide_dp = prs.slides.add_slide(blank_layout)
274
- _apply_theme_decorations(slide_dp, prs)
275
- _add_slide_title(slide_dp, "DuPont ROE Analysis (Latest 3 Years)")
276
-
277
- dp_years = dp_data.get("years", [])
278
- cols = len(dp_years) + 1
279
- rows = 5
280
- table_shape = slide_dp.shapes.add_table(
281
- rows, cols, Inches(0.8), Inches(1.6), Inches(11.7), Inches(3.2)
282
- )
283
- table = table_shape.table
284
-
285
- # Headers
286
- _style_table_cell(table.cell(0, 0), "DuPont Component", FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE, PP_ALIGN.LEFT)
287
- for idx, y in enumerate(dp_years):
288
- _style_table_cell(table.cell(0, idx+1), str(y), FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE)
289
-
290
- components = [
291
- ("Net Profit Margin (%)", "Net Margin"),
292
- ("Asset Turnover (x)", "Asset Turnover"),
293
- ("Equity Multiplier (x)", "Equity Multiplier"),
294
- ("Return on Equity (ROE %)", "ROE")
295
- ]
296
-
297
- for r_idx, (c_label, key) in enumerate(components):
298
- curr_row = r_idx + 1
299
- bg_color = COLOR_LIGHT_GRAY if curr_row % 2 == 1 else COLOR_LIGHT_BG
300
- is_bold = (curr_row == 4) # Bold ROE row
301
-
302
- _style_table_cell(table.cell(curr_row, 0), c_label, FONT_MAJOR if is_bold else FONT_MINOR, 11, is_bold, COLOR_DARK_TEXT, bg_color, PP_ALIGN.LEFT)
303
-
304
- vals_list = dp_data.get(key, [])
305
- for c_idx, y in enumerate(dp_years):
306
- val_str = "N/A"
307
- if c_idx < len(vals_list) and vals_list[c_idx] is not None:
308
- val_val = vals_list[c_idx]
309
- if "%" in c_label or "ROE" in key:
310
- val_str = f"{val_val * 100:.1f}%" if val_val < 1.0 else f"{val_val:.1f}%"
311
- else:
312
- val_str = f"{val_val:.2f}x"
313
- _style_table_cell(table.cell(curr_row, c_idx+1), val_str, FONT_MAJOR if is_bold else FONT_MINOR, 11, is_bold, COLOR_DARK_TEXT, bg_color)
314
-
315
- # DuPont Commentary Text Box
316
- commentary = data.get("trend_analysis", {}).get("balance_sheet_strength", "")
317
- if commentary:
318
- comment_box = slide_dp.shapes.add_textbox(Inches(0.8), Inches(5.0), Inches(11.7), Inches(1.6))
319
- tf_c = comment_box.text_frame
320
- tf_c.word_wrap = True
321
- p_c = tf_c.paragraphs[0]
322
- p_c.text = "DuPont Drivers & Balance Sheet Strength: " + (commentary[:380] + "..." if len(commentary) > 380 else commentary)
323
- p_c.font.name = FONT_MINOR
324
- p_c.font.size = Pt(12)
325
- p_c.font.color.rgb = COLOR_DARK_TEXT
326
- p_c.line_spacing = 1.2
327
-
328
- # ==========================================
329
- # SLIDE 5: Competitor Benchmarking
330
- # ==========================================
331
- peers_list = data.get("peers", [])
332
- if peers_list:
333
- slide_peers = prs.slides.add_slide(blank_layout)
334
- _apply_theme_decorations(slide_peers, prs)
335
- _add_slide_title(slide_peers, "Competitor Benchmarking")
336
-
337
- # Build Grid Table
338
- cols = 5
339
- rows = min(len(peers_list) + 1, 6)
340
- table_shape = slide_peers.shapes.add_table(
341
- rows, cols, Inches(0.8), Inches(1.6), Inches(11.7), Inches(3.2)
342
- )
343
- table = table_shape.table
344
-
345
- # Headers
346
- headers = ["Company", "Revenue Growth", "Operating Margin", "P/E Ratio", "ROE"]
347
- for c_idx, h in enumerate(headers):
348
- _style_table_cell(table.cell(0, c_idx), h, FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE, PP_ALIGN.LEFT if c_idx == 0 else PP_ALIGN.CENTER)
349
-
350
- for r_idx in range(rows - 1):
351
- peer = peers_list[r_idx]
352
- curr_row = r_idx + 1
353
- bg_color = COLOR_LIGHT_GRAY if curr_row % 2 == 1 else COLOR_LIGHT_BG
354
-
355
- # Format peer outputs
356
- rev_gr = peer.get("revenue_growth", "N/A")
357
- if isinstance(rev_gr, float): rev_gr = f"{rev_gr*100:.1f}%"
358
- op_marg = peer.get("operating_margin", "N/A")
359
- if isinstance(op_marg, float): op_marg = f"{op_marg*100:.1f}%"
360
- pe_r = peer.get("pe_ratio", "N/A")
361
- if isinstance(pe_r, float): pe_r = f"{pe_r:.1f}x"
362
- roe_val = peer.get("roe", "N/A")
363
- if isinstance(roe_val, float): roe_val = f"{roe_val*100:.1f}%"
364
-
365
- _style_table_cell(table.cell(curr_row, 0), peer.get("name", "N/A"), FONT_MAJOR, 11, False, COLOR_DARK_TEXT, bg_color, PP_ALIGN.LEFT)
366
- _style_table_cell(table.cell(curr_row, 1), str(rev_gr), FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color)
367
- _style_table_cell(table.cell(curr_row, 2), str(op_marg), FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color)
368
- _style_table_cell(table.cell(curr_row, 3), str(pe_r), FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color)
369
- _style_table_cell(table.cell(curr_row, 4), str(roe_val), FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color)
370
-
371
- # Peer comparison commentary
372
- peer_comm = data.get("peer_comparison_commentary", "")
373
- if peer_comm:
374
- comment_box = slide_peers.shapes.add_textbox(Inches(0.8), Inches(5.0), Inches(11.7), Inches(1.6))
375
- tf_c = comment_box.text_frame
376
- tf_c.word_wrap = True
377
- p_c = tf_c.paragraphs[0]
378
- p_c.text = "Benchmarking Insights: " + (peer_comm[:380] + "..." if len(peer_comm) > 380 else peer_comm)
379
- p_c.font.name = FONT_MINOR
380
- p_c.font.size = Pt(12)
381
- p_c.font.color.rgb = COLOR_DARK_TEXT
382
- p_c.line_spacing = 1.2
383
-
384
- # ==========================================
385
- # SLIDE 6: Financial Visualizations
386
- # ==========================================
387
- try:
388
- from builders.chart_builder import generate_all_charts
389
- charts = generate_all_charts(data)
390
-
391
- for chart_key, title in [
392
- ("revenue_net_income", "Revenue & Net Income Trend"),
393
- ("margins", "Profitability Margins"),
394
- ("cash_flow", "Cash Flow & CAPEX Overview")
395
- ]:
396
- if charts.get(chart_key):
397
- slide_chart = prs.slides.add_slide(blank_layout)
398
- _apply_theme_decorations(slide_chart, prs)
399
- _add_slide_title(slide_chart, title)
400
-
401
- # Add picture centrally
402
- slide_chart.shapes.add_picture(
403
- charts[chart_key],
404
- Inches(1.5), Inches(1.5), width=Inches(10.0)
405
- )
406
- except Exception as e:
407
- import sys
408
- print(f"Warning: Could not generate PPTX charts: {e}", file=sys.stderr)
409
-
410
- # ==========================================
411
- # SLIDE 7: Key Synthesis Insights
412
- # ==========================================
413
- insights_list = data.get("key_insights", [])
414
- if insights_list:
415
- slide_insights = prs.slides.add_slide(blank_layout)
416
- _apply_theme_decorations(slide_insights, prs)
417
- _add_slide_title(slide_insights, "Synthesized Key Insights")
418
-
419
- # Clean insights
420
- clean_insights = []
421
- for ins in insights_list:
422
- if isinstance(ins, dict):
423
- clean_insights.append(f"{ins.get('title', '')}: {ins.get('text', '')}")
424
- else:
425
- clean_insights.append(str(ins))
426
-
427
- _add_bullet_points(slide_insights, clean_insights[:4])
428
-
429
- # ==========================================
430
- # SLIDE 7: Risks & Red Flags
431
- # ==========================================
432
- risks_list = data.get("risks", [])
433
- if risks_list:
434
- slide_risks = prs.slides.add_slide(blank_layout)
435
- _apply_theme_decorations(slide_risks, prs)
436
- _add_slide_title(slide_risks, "Investment Risks & Key Red Flags")
437
-
438
- clean_risks = []
439
- for r in risks_list:
440
- if isinstance(r, dict):
441
- clean_risks.append(f"{r.get('title', '')} ({r.get('impact', 'Medium Impact')}): {r.get('text', '')}")
442
- else:
443
- clean_risks.append(str(r))
444
-
445
- _add_bullet_points(slide_risks, clean_risks[:4])
446
-
447
- # Save Presentation
448
- os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
449
- prs.save(output_path)
 
1
+ """
2
+ builders/build_pptx.py β€” PowerPoint presentation builder.
3
+
4
+ Generates a widescreen (16:9) PPTX presentation summarizing the financial analysis
5
+ and AI narrative, strictly matching user-defined theme, colors, and layout structure.
6
+ """
7
+ import os
8
+ import sys
9
+ import datetime
10
+ from pathlib import Path
11
+ from typing import Dict, List
12
+
13
+ # import python-pptx classes
14
+ from pptx import Presentation
15
+ from pptx.util import Inches, Pt
16
+ from pptx.dml.color import RGBColor
17
+ from pptx.enum.text import PP_ALIGN
18
+ from pptx.enum.shapes import MSO_SHAPE
19
+ from pptx.enum.shapes import MSO_CONNECTOR
20
+
21
+ # Color Palette defined in User Rules
22
+ COLOR_DARK_TEXT = RGBColor(0, 0, 0) # 000000 (Black)
23
+ COLOR_LIGHT_BG = RGBColor(255, 255, 255) # FFFFFF (White)
24
+ COLOR_DARK_GREEN = RGBColor(0, 132, 0) # 008400 (Green Bar 1)
25
+ COLOR_LIGHT_GREEN = RGBColor(149, 203, 137) # 95CB89 (Green Bar 2)
26
+ COLOR_GRAY_LINE = RGBColor(0, 0, 0) # 000000 (Black bottom line)
27
+ COLOR_DARK_SLATE = RGBColor(69, 95, 81) # 455F51 (Dark slate green)
28
+ COLOR_LIGHT_GRAY = RGBColor(227, 222, 209) # E3DED1 (Light warm gray/beige)
29
+ COLOR_ACCENT = RGBColor(84, 158, 57) # 549E39 (Accent green)
30
+
31
+ FONT_MAJOR = "Helvetica Neue Bold"
32
+ FONT_MINOR = "Helvetica Neue Light"
33
+ FONT_MAJOR_FALLBACK = "Arial"
34
+ FONT_MINOR_FALLBACK = "Arial"
35
+
36
+ def _apply_theme_decorations(slide, prs):
37
+ """
38
+ Applies the layout rectangles and bottom line strictly defined in the
39
+ Slide Master Layout.
40
+ """
41
+ # 1. Decorative top-left dark green bar
42
+ # rect: x: 0.15, y: 0.0, w: 0.36, h: 1.16
43
+ rect1 = slide.shapes.add_shape(
44
+ MSO_SHAPE.RECTANGLE,
45
+ Inches(0.15), Inches(0.0), Inches(0.36), Inches(1.16)
46
+ )
47
+ rect1.fill.solid()
48
+ rect1.fill.fore_color.rgb = COLOR_DARK_GREEN
49
+ rect1.line.fill.background() # No border line
50
+
51
+ # 2. Decorative top-left light green bar
52
+ # rect: x: 0.55, y: 0.0, w: 0.11, h: 1.39
53
+ rect2 = slide.shapes.add_shape(
54
+ MSO_SHAPE.RECTANGLE,
55
+ Inches(0.55), Inches(0.0), Inches(0.11), Inches(1.39)
56
+ )
57
+ rect2.fill.solid()
58
+ rect2.fill.fore_color.rgb = COLOR_LIGHT_GREEN
59
+ rect2.line.fill.background() # No border line
60
+
61
+ # 3. Decorative bottom line
62
+ # line: x: 0.67, y: 6.83, w: 12.0, h: 0.0
63
+ connector = slide.shapes.add_connector(
64
+ MSO_CONNECTOR.STRAIGHT,
65
+ Inches(0.67), Inches(6.83), Inches(12.67), Inches(6.83)
66
+ )
67
+ connector.line.color.rgb = COLOR_GRAY_LINE
68
+ connector.line.width = Pt(1)
69
+
70
+ def _add_slide_title(slide, text: str):
71
+ """
72
+ Adds the Slide Title using Layout 7 Title configurations:
73
+ placeholder: x: 0.81, y: 0.29, w: 11.74, h: 0.38, align: left, valign: top
74
+ """
75
+ tx_box = slide.shapes.add_textbox(
76
+ Inches(0.81), Inches(0.29), Inches(11.74), Inches(0.38)
77
+ )
78
+ tf = tx_box.text_frame
79
+ tf.word_wrap = True
80
+ tf.margin_top = 0
81
+ tf.margin_bottom = 0
82
+ tf.margin_left = 0
83
+ tf.margin_right = 0
84
+
85
+ p = tf.paragraphs[0]
86
+ p.text = text
87
+ p.font.name = FONT_MAJOR
88
+ p.font.size = Pt(20)
89
+ p.font.bold = True
90
+ p.font.color.rgb = COLOR_DARK_TEXT
91
+ p.alignment = PP_ALIGN.LEFT
92
+
93
+ def _add_bullet_points(slide, bullets: List[str], top_inch=1.56, height_inch=5.14):
94
+ """
95
+ Adds bullet points body text block using Master 2 Body:
96
+ placeholder: x: 0.67, y: top, w: 12.0, h: height
97
+ """
98
+ tx_box = slide.shapes.add_textbox(
99
+ Inches(0.67), Inches(top_inch), Inches(12.0), Inches(height_inch)
100
+ )
101
+ tf = tx_box.text_frame
102
+ tf.word_wrap = True
103
+ tf.margin_top = 0
104
+ tf.margin_bottom = 0
105
+ tf.margin_left = 0
106
+ tf.margin_right = 0
107
+
108
+ for i, b in enumerate(bullets):
109
+ p = tf.add_paragraph() if i > 0 else tf.paragraphs[0]
110
+ p.text = "β€’ " + b
111
+ p.font.name = FONT_MINOR
112
+ p.font.size = Pt(15)
113
+ p.font.color.rgb = COLOR_DARK_TEXT
114
+ p.space_after = Pt(8)
115
+ p.line_spacing = 1.2
116
+
117
+ def _style_table_cell(cell, text: str, font_name=FONT_MINOR, font_size=11, bold=False, text_color=COLOR_DARK_TEXT, bg_color=None, align=PP_ALIGN.CENTER):
118
+ """Styles cell texts and background fills."""
119
+ cell.text = text
120
+ p = cell.text_frame.paragraphs[0]
121
+ p.alignment = align
122
+ p.font.name = font_name
123
+ p.font.size = Pt(font_size)
124
+ p.font.bold = bold
125
+ p.font.color.rgb = text_color
126
+
127
+ if bg_color:
128
+ cell.fill.solid()
129
+ cell.fill.fore_color.rgb = bg_color
130
+
131
+ def build_presentation(data: Dict, output_path: str):
132
+ """
133
+ Assembles a widescreen PowerPoint report from scraped financial data and AI models output.
134
+ """
135
+ prs = Presentation()
136
+
137
+ # Strictly configure widescreen 16:9 aspect ratio
138
+ prs.slide_width = Inches(13.333)
139
+ prs.slide_height = Inches(7.5)
140
+
141
+ blank_layout = prs.slide_layouts[6] # Blank slide template
142
+
143
+ company_name = data.get("company", {}).get("name", "Unknown Company")
144
+ ticker = data.get("company", {}).get("ticker", "N/A")
145
+ industry = data.get("company", {}).get("industry", "N/A")
146
+ sector = data.get("company", {}).get("sector", "N/A")
147
+ date_str = data.get("company", {}).get("report_date", datetime.date.today().isoformat())
148
+ years = data.get("years", [])
149
+
150
+ # ==========================================
151
+ # SLIDE 1: Title Slide
152
+ # ==========================================
153
+ slide_title = prs.slides.add_slide(blank_layout)
154
+ _apply_theme_decorations(slide_title, prs)
155
+
156
+ # Title Text Frame
157
+ title_box = slide_title.shapes.add_textbox(
158
+ Inches(1.0), Inches(2.2), Inches(11.3), Inches(2.0)
159
+ )
160
+ tf = title_box.text_frame
161
+ tf.word_wrap = True
162
+ p1 = tf.paragraphs[0]
163
+ p1.text = company_name
164
+ p1.font.name = FONT_MAJOR
165
+ p1.font.size = Pt(40)
166
+ p1.font.bold = True
167
+ p1.font.color.rgb = COLOR_DARK_TEXT
168
+
169
+ p2 = tf.add_paragraph()
170
+ p2.text = f"Financial Analysis & Investment Insights ({ticker})"
171
+ p2.font.name = FONT_MINOR
172
+ p2.font.size = Pt(22)
173
+ p2.font.color.rgb = COLOR_DARK_SLATE
174
+ p2.space_before = Pt(8)
175
+
176
+ # Subtitle Info (Date / Authors)
177
+ sub_box = slide_title.shapes.add_textbox(
178
+ Inches(1.0), Inches(5.0), Inches(11.3), Inches(1.2)
179
+ )
180
+ tf_sub = sub_box.text_frame
181
+ p_sub = tf_sub.paragraphs[0]
182
+ p_sub.text = f"Report Date: {date_str} | Sector: {sector} | Industry: {industry}"
183
+ p_sub.font.name = FONT_MINOR
184
+ p_sub.font.size = Pt(13)
185
+ p_sub.font.color.rgb = COLOR_DARK_SLATE
186
+
187
+ p_sub2 = tf_sub.add_paragraph()
188
+ p_sub2.text = "Disclaimer: This report is for informational purposes only and does not constitute investment advice."
189
+ p_sub2.font.name = FONT_MINOR
190
+ p_sub2.font.size = Pt(11)
191
+ p_sub2.font.color.rgb = COLOR_DARK_SLATE
192
+ p_sub2.space_before = Pt(6)
193
+
194
+ # ==========================================
195
+ # DYNAMIC AI SLIDES (From ppt_json)
196
+ # ==========================================
197
+ import json
198
+ import re
199
+
200
+ ppt_json_str = data.get("ppt_json", "")
201
+ ai_slides = []
202
+ if ppt_json_str:
203
+ match = re.search(r'\[.*\]', ppt_json_str, re.DOTALL)
204
+ if match:
205
+ try:
206
+ ai_slides = json.loads(match.group(0))
207
+ except Exception:
208
+ pass
209
+
210
+ if ai_slides:
211
+ for slide_data in ai_slides:
212
+ title = slide_data.get("title", "Analysis Slide")
213
+ bullets = slide_data.get("bullets", [])
214
+
215
+ if not bullets: continue
216
+
217
+ s = prs.slides.add_slide(blank_layout)
218
+ _apply_theme_decorations(s, prs)
219
+ _add_slide_title(s, title)
220
+
221
+ clean_bullets = [str(b).strip() for b in bullets if b]
222
+ _add_bullet_points(s, clean_bullets)
223
+ else:
224
+ # Fallback to Exec Summary if JSON parsing failed
225
+ exec_summary_text = data.get("executive_summary", "")
226
+ if exec_summary_text and len(exec_summary_text) > 50:
227
+ slide_exec = prs.slides.add_slide(blank_layout)
228
+ _apply_theme_decorations(slide_exec, prs)
229
+ _add_slide_title(slide_exec, "Executive Summary")
230
+
231
+ paragraphs = [p.strip() for p in exec_summary_text.split('\n') if p.strip()]
232
+ _add_bullet_points(slide_exec, paragraphs[:5])
233
+
234
+ # ==========================================
235
+ # SLIDE 3: Key Financial Highlights Table
236
+ # ==========================================
237
+ if years:
238
+ slide_metrics = prs.slides.add_slide(blank_layout)
239
+ _apply_theme_decorations(slide_metrics, prs)
240
+ _add_slide_title(slide_metrics, "Key Financial Performance")
241
+
242
+ # Build Grid Table
243
+ cols = len(years) + 1
244
+ rows = 6
245
+ table_shape = slide_metrics.shapes.add_table(
246
+ rows, cols, Inches(0.8), Inches(1.6), Inches(11.7), Inches(3.8)
247
+ )
248
+ table = table_shape.table
249
+
250
+ # Column Headers
251
+ _style_table_cell(table.cell(0, 0), "Financial Metric", FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE, PP_ALIGN.LEFT)
252
+ for idx, y in enumerate(years):
253
+ _style_table_cell(table.cell(0, idx+1), str(y), FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE)
254
+
255
+ metrics = [
256
+ ("Revenue", "revenue", "income_statement", 1.0),
257
+ ("Gross Profit", "gross_profit", "income_statement", 1.0),
258
+ ("Operating Income (EBIT)", "operating_income", "income_statement", 1.0),
259
+ ("Net Income", "net_income", "income_statement", 1.0),
260
+ ("Operating Cash Flow (OCF)", "cash_from_operations", "cash_flow", 1.0)
261
+ ]
262
+
263
+ currency = data.get("company", {}).get("currency", "USD")
264
+ units = data.get("company", {}).get("units", "millions")
265
+
266
+ for r_idx, (m_label, dict_key, sheet_name, scale) in enumerate(metrics):
267
+ curr_row = r_idx + 1
268
+ bg_color = COLOR_LIGHT_GRAY if curr_row % 2 == 1 else COLOR_LIGHT_BG
269
+
270
+ # Label cell
271
+ _style_table_cell(table.cell(curr_row, 0), f"{m_label} ({currency} {units.capitalize()})", FONT_MAJOR, 11, False, COLOR_DARK_TEXT, bg_color, PP_ALIGN.LEFT)
272
+
273
+ # Values cells
274
+ sheet_data = data.get(sheet_name, {})
275
+ val_list = sheet_data.get(dict_key, [])
276
+ for c_idx, y in enumerate(years):
277
+ val_str = "N/A"
278
+ if c_idx < len(val_list) and val_list[c_idx] is not None:
279
+ val_str = f"{val_list[c_idx]:,.1f}"
280
+ _style_table_cell(table.cell(curr_row, c_idx+1), val_str, FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color)
281
+
282
+ # Brief Narrative summary underneath table
283
+ rev_commentary = data.get("trend_analysis", {}).get("revenue_and_profitability", "")
284
+ if rev_commentary:
285
+ comment_box = slide_metrics.shapes.add_textbox(Inches(0.8), Inches(5.6), Inches(11.7), Inches(1.1))
286
+ tf_c = comment_box.text_frame
287
+ tf_c.word_wrap = True
288
+ p_c = tf_c.paragraphs[0]
289
+ p_c.text = "Key Takeaway: " + (rev_commentary[:260] + "..." if len(rev_commentary) > 260 else rev_commentary)
290
+ p_c.font.name = FONT_MINOR
291
+ p_c.font.size = Pt(12)
292
+ p_c.font.italic = True
293
+ p_c.font.color.rgb = COLOR_DARK_SLATE
294
+
295
+ # ==========================================
296
+ # SLIDE 4: Dupont ROE Decomposition
297
+ # ==========================================
298
+ dp_data = data.get("dupont", {})
299
+ if dp_data and "years" in dp_data:
300
+ slide_dp = prs.slides.add_slide(blank_layout)
301
+ _apply_theme_decorations(slide_dp, prs)
302
+ _add_slide_title(slide_dp, "DuPont ROE Analysis (Latest 3 Years)")
303
+
304
+ dp_years = dp_data.get("years", [])
305
+ cols = len(dp_years) + 1
306
+ rows = 5
307
+ table_shape = slide_dp.shapes.add_table(
308
+ rows, cols, Inches(0.8), Inches(1.6), Inches(11.7), Inches(3.2)
309
+ )
310
+ table = table_shape.table
311
+
312
+ # Headers
313
+ _style_table_cell(table.cell(0, 0), "DuPont Component", FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE, PP_ALIGN.LEFT)
314
+ for idx, y in enumerate(dp_years):
315
+ _style_table_cell(table.cell(0, idx+1), str(y), FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE)
316
+
317
+ components = [
318
+ ("Net Profit Margin (%)", "Net Margin"),
319
+ ("Asset Turnover (x)", "Asset Turnover"),
320
+ ("Equity Multiplier (x)", "Equity Multiplier"),
321
+ ("Return on Equity (ROE %)", "ROE")
322
+ ]
323
+
324
+ for r_idx, (c_label, key) in enumerate(components):
325
+ curr_row = r_idx + 1
326
+ bg_color = COLOR_LIGHT_GRAY if curr_row % 2 == 1 else COLOR_LIGHT_BG
327
+ is_bold = (curr_row == 4) # Bold ROE row
328
+
329
+ _style_table_cell(table.cell(curr_row, 0), c_label, FONT_MAJOR if is_bold else FONT_MINOR, 11, is_bold, COLOR_DARK_TEXT, bg_color, PP_ALIGN.LEFT)
330
+
331
+ vals_list = dp_data.get(key, [])
332
+ for c_idx, y in enumerate(dp_years):
333
+ val_str = "N/A"
334
+ if c_idx < len(vals_list) and vals_list[c_idx] is not None:
335
+ val_val = vals_list[c_idx]
336
+ if "%" in c_label or "ROE" in key:
337
+ val_str = f"{val_val * 100:.1f}%" if val_val < 1.0 else f"{val_val:.1f}%"
338
+ else:
339
+ val_str = f"{val_val:.2f}x"
340
+ _style_table_cell(table.cell(curr_row, c_idx+1), val_str, FONT_MAJOR if is_bold else FONT_MINOR, 11, is_bold, COLOR_DARK_TEXT, bg_color)
341
+
342
+ # DuPont Commentary Text Box
343
+ commentary = data.get("trend_analysis", {}).get("balance_sheet_strength", "")
344
+ if commentary:
345
+ comment_box = slide_dp.shapes.add_textbox(Inches(0.8), Inches(5.0), Inches(11.7), Inches(1.6))
346
+ tf_c = comment_box.text_frame
347
+ tf_c.word_wrap = True
348
+ p_c = tf_c.paragraphs[0]
349
+ p_c.text = "DuPont Drivers & Balance Sheet Strength: " + (commentary[:380] + "..." if len(commentary) > 380 else commentary)
350
+ p_c.font.name = FONT_MINOR
351
+ p_c.font.size = Pt(12)
352
+ p_c.font.color.rgb = COLOR_DARK_TEXT
353
+ p_c.line_spacing = 1.2
354
+
355
+ # ==========================================
356
+ # SLIDE 5: Competitor Benchmarking
357
+ # ==========================================
358
+ peers_list = data.get("peers", [])
359
+ if peers_list:
360
+ slide_peers = prs.slides.add_slide(blank_layout)
361
+ _apply_theme_decorations(slide_peers, prs)
362
+ _add_slide_title(slide_peers, "Competitor Benchmarking")
363
+
364
+ # Build Grid Table
365
+ cols = 5
366
+ rows = min(len(peers_list) + 1, 6)
367
+ table_shape = slide_peers.shapes.add_table(
368
+ rows, cols, Inches(0.8), Inches(1.6), Inches(11.7), Inches(3.2)
369
+ )
370
+ table = table_shape.table
371
+
372
+ # Headers
373
+ headers = ["Company", "Revenue Growth", "Operating Margin", "P/E Ratio", "ROE"]
374
+ for c_idx, h in enumerate(headers):
375
+ _style_table_cell(table.cell(0, c_idx), h, FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE, PP_ALIGN.LEFT if c_idx == 0 else PP_ALIGN.CENTER)
376
+
377
+ for r_idx in range(rows - 1):
378
+ peer = peers_list[r_idx]
379
+ curr_row = r_idx + 1
380
+ bg_color = COLOR_LIGHT_GRAY if curr_row % 2 == 1 else COLOR_LIGHT_BG
381
+
382
+ # Format peer outputs
383
+ rev_gr = peer.get("revenue_growth", "N/A")
384
+ if isinstance(rev_gr, float): rev_gr = f"{rev_gr*100:.1f}%"
385
+ op_marg = peer.get("operating_margin", "N/A")
386
+ if isinstance(op_marg, float): op_marg = f"{op_marg*100:.1f}%"
387
+ pe_r = peer.get("pe_ratio", "N/A")
388
+ if isinstance(pe_r, float): pe_r = f"{pe_r:.1f}x"
389
+ roe_val = peer.get("roe", "N/A")
390
+ if isinstance(roe_val, float): roe_val = f"{roe_val*100:.1f}%"
391
+
392
+ _style_table_cell(table.cell(curr_row, 0), peer.get("name", "N/A"), FONT_MAJOR, 11, False, COLOR_DARK_TEXT, bg_color, PP_ALIGN.LEFT)
393
+ _style_table_cell(table.cell(curr_row, 1), str(rev_gr), FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color)
394
+ _style_table_cell(table.cell(curr_row, 2), str(op_marg), FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color)
395
+ _style_table_cell(table.cell(curr_row, 3), str(pe_r), FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color)
396
+ _style_table_cell(table.cell(curr_row, 4), str(roe_val), FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color)
397
+
398
+ # Peer comparison commentary
399
+ peer_comm = data.get("peer_comparison_commentary", "")
400
+ if peer_comm:
401
+ comment_box = slide_peers.shapes.add_textbox(Inches(0.8), Inches(5.0), Inches(11.7), Inches(1.6))
402
+ tf_c = comment_box.text_frame
403
+ tf_c.word_wrap = True
404
+ p_c = tf_c.paragraphs[0]
405
+ p_c.text = "Benchmarking Insights: " + (peer_comm[:380] + "..." if len(peer_comm) > 380 else peer_comm)
406
+ p_c.font.name = FONT_MINOR
407
+ p_c.font.size = Pt(12)
408
+ p_c.font.color.rgb = COLOR_DARK_TEXT
409
+ p_c.line_spacing = 1.2
410
+
411
+ # ==========================================
412
+ # SLIDE 6: Financial Visualizations
413
+ # ==========================================
414
+ try:
415
+ from builders.chart_builder import generate_all_charts
416
+ charts = generate_all_charts(data)
417
+
418
+ for chart_key, title in [
419
+ ("revenue_net_income", "Revenue & Net Income Trend"),
420
+ ("margins", "Profitability Margins"),
421
+ ("cash_flow", "Cash Flow & CAPEX Overview")
422
+ ]:
423
+ if charts.get(chart_key):
424
+ slide_chart = prs.slides.add_slide(blank_layout)
425
+ _apply_theme_decorations(slide_chart, prs)
426
+ _add_slide_title(slide_chart, title)
427
+
428
+ # Add picture centrally
429
+ slide_chart.shapes.add_picture(
430
+ charts[chart_key],
431
+ Inches(1.5), Inches(1.5), width=Inches(10.0)
432
+ )
433
+ except Exception as e:
434
+ import sys
435
+ print(f"Warning: Could not generate PPTX charts: {e}", file=sys.stderr)
436
+
437
+ # Save Presentation
438
+ os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
439
+ prs.save(output_path)
 
 
 
 
 
 
 
 
 
 
hf_source.txt ADDED
Binary file (22.8 kB). View file
 
pipeline/analyst.py CHANGED
@@ -209,7 +209,7 @@ Guidelines:
209
  - Numbers should be expressed in appropriate units (Cr for large INR amounts, B/M for USD)
210
  """
211
 
212
- # ── Synthesis Prompt ──────────────────────────────────────────────────────────
213
 
214
  SYNTHESIS_PROMPT = """You are a master financial editor. You have been given {num_drafts} different AI-generated drafts for the '{section}' section of a financial report.
215
  Your job is to synthesize these drafts into a single, cohesive, authoritative, and brilliantly written section.
@@ -222,6 +222,16 @@ Guidelines:
222
  - Output ONLY the final synthesized text, with NO markdown formatting, NO introductory text, and NO commentary. Just the plain text paragraphs.
223
  """
224
 
 
 
 
 
 
 
 
 
 
 
225
  # ── Section prompts ───────────────────────────────────────────────────────────
226
 
227
  SECTION_PROMPTS = {
@@ -659,14 +669,38 @@ def generate_narrative(
659
  if log_fn: log_fn(f"[Warning] {name} thread failed: {e}")
660
 
661
  if len(drafts) == 0:
662
- return f"[{section}: All AI models failed to generate a response]"
663
- elif len(drafts) == 1:
664
  return list(drafts.values())[0]
665
- else:
666
- return _synthesize_drafts(section, drafts)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
667
 
668
- # Generate all sections
669
- log(" Generating executive summary …")
670
  exec_summary = gen("executive_summary")
671
 
672
  log(" Generating company profile …")
@@ -708,6 +742,8 @@ def generate_narrative(
708
  log(" Generating limitations …")
709
  lim_raw = gen("limitations")
710
  limitations = _parse_bullets(lim_raw)
 
 
711
 
712
  log(" βœ“ AI narrative generation complete")
713
 
@@ -729,6 +765,7 @@ def generate_narrative(
729
  "things_to_watch": things_to_watch,
730
  "limitations_and_caveats": limitations,
731
  "models_used": models,
 
732
  }
733
 
734
 
@@ -747,4 +784,5 @@ def _empty_narrative() -> Dict:
747
  "things_to_watch": [],
748
  "limitations_and_caveats": [],
749
  "models_used": [],
 
750
  }
 
209
  - Numbers should be expressed in appropriate units (Cr for large INR amounts, B/M for USD)
210
  """
211
 
212
+ # ── Synthesis & PPT Prompts ──────────────────────────────────────────────────────────
213
 
214
  SYNTHESIS_PROMPT = """You are a master financial editor. You have been given {num_drafts} different AI-generated drafts for the '{section}' section of a financial report.
215
  Your job is to synthesize these drafts into a single, cohesive, authoritative, and brilliantly written section.
 
222
  - Output ONLY the final synthesized text, with NO markdown formatting, NO introductory text, and NO commentary. Just the plain text paragraphs.
223
  """
224
 
225
+ GENERATE_PPT_JSON = """You are a professional investment banker creating a pitch deck from financial data.
226
+ Your task is to generate the structure and content for a PowerPoint presentation summarizing the company's financial health.
227
+ Output a strict JSON array of slide objects. Each slide object MUST have:
228
+ - "title": A short, punchy title for the slide
229
+ - "bullets": An array of 3-5 strings, each containing a compelling insight, metric, or trend based on the data.
230
+
231
+ Do not include any markdown fences, introduction, or conclusion. Just the raw JSON array.
232
+ Focus on: Executive Summary, Revenue & Margin Trends, Cash Flow Health, Valuation, and Key Risks.
233
+ """
234
+
235
  # ── Section prompts ───────────────────────────────────────────────────────────
236
 
237
  SECTION_PROMPTS = {
 
669
  if log_fn: log_fn(f"[Warning] {name} thread failed: {e}")
670
 
671
  if len(drafts) == 0:
672
+ return ""
673
+ if len(drafts) == 1:
674
  return list(drafts.values())[0]
675
+
676
+ return _synthesize_drafts(section, drafts)
677
+
678
+ def _generate_ppt_json() -> str:
679
+ # Pick the smartest model available for structured JSON generation
680
+ synthesizer = None
681
+ if use_openai: synthesizer = ("ChatGPT", _openai_generate)
682
+ elif use_claude: synthesizer = ("Claude", _claude_generate)
683
+ elif use_gemini: synthesizer = ("Gemini", _gemini_generate)
684
+ elif use_nvidia: synthesizer = ("NVIDIA", _nvidia_generate)
685
+ elif use_groq: synthesizer = ("Groq", _groq_generate)
686
+
687
+ if not synthesizer:
688
+ return ""
689
+
690
+ name, fn = synthesizer
691
+ log(f"\n[PPT] Generating PowerPoint Structure using {name} ...")
692
+
693
+ original_prompt = SECTION_PROMPTS.get("ppt_json", "")
694
+ SECTION_PROMPTS["ppt_json"] = GENERATE_PPT_JSON
695
+ try:
696
+ res = fn(context, "ppt_json")
697
+ finally:
698
+ if "ppt_json" in SECTION_PROMPTS:
699
+ del SECTION_PROMPTS["ppt_json"]
700
+
701
+ return res if res else ""
702
 
703
+ log(" > Executing section: Executive Summary")
 
704
  exec_summary = gen("executive_summary")
705
 
706
  log(" Generating company profile …")
 
742
  log(" Generating limitations …")
743
  lim_raw = gen("limitations")
744
  limitations = _parse_bullets(lim_raw)
745
+
746
+ ppt_json = _generate_ppt_json()
747
 
748
  log(" βœ“ AI narrative generation complete")
749
 
 
765
  "things_to_watch": things_to_watch,
766
  "limitations_and_caveats": limitations,
767
  "models_used": models,
768
+ "ppt_json": ppt_json,
769
  }
770
 
771
 
 
784
  "things_to_watch": [],
785
  "limitations_and_caveats": [],
786
  "models_used": [],
787
+ "ppt_json": "",
788
  }
pipeline/fetcher.py CHANGED
@@ -74,85 +74,79 @@ def _fetch_yfinance(ticker: str, years: int, log_fn=None) -> Optional[Dict]:
74
  for c in use_cols_asc: # iterate oldestβ†’newest
75
  if c in df.columns:
76
  v = df.at[k, c]
77
- vals.append(None if (v != v or v is None) else float(v))
78
- else:
79
- vals.append(None)
80
- return vals
81
- return [None] * n
82
-
83
- currency = info.get("currency", "INR")
84
-
85
- # Determine display scale
86
- # INR: raw values are in rupees β†’ divide by 1e7 to get Crores
87
- # USD/others: raw values in dollars β†’ divide by 1e6 for Millions
88
  if currency == "INR":
89
- scale = 1e7 # rupees β†’ crores
90
  units = "Crores"
91
  units_label = "INR Crores (β‚Ή Cr)"
92
  else:
93
- scale = 1e6 # dollars β†’ millions
94
  units = "Millions"
95
  units_label = f"{currency} Millions"
96
 
 
 
 
 
 
 
97
  def sc(lst):
98
- """Scale a list of raw values."""
99
- return [round(v / scale, 2) if v is not None else None for v in lst]
100
 
101
  income = {
102
- "revenue": sc(col(fin, "Total Revenue", "Operating Revenue")),
103
- "cost_of_revenue": sc(col(fin, "Cost Of Revenue", "Reconciled Cost Of Revenue")),
104
- "gross_profit": sc(col(fin, "Gross Profit")),
105
- "operating_expenses": sc(col(fin, "Operating Expense")),
106
- "operating_income": sc(col(fin, "Operating Income", "EBIT")),
107
- "ebitda": sc(col(fin, "EBITDA", "Normalized EBITDA")),
108
- "depreciation_amort": sc(col(fin, "Reconciled Depreciation", "Depreciation And Amortization")),
109
- "interest_expense": sc(col(fin, "Interest Expense Non Operating", "Interest Expense")),
110
- "pretax_income": sc(col(fin, "Pretax Income")),
111
- "tax_expense": sc(col(fin, "Tax Provision")),
112
- "net_income": sc(col(fin, "Net Income", "Net Income Common Stockholders")),
113
- "diluted_eps": col(fin, "Diluted EPS"), # EPS not scaled
114
- "diluted_shares": col(fin, "Diluted Average Shares"), # not scaled
115
  }
116
 
117
  balance = {
118
- "cash_and_equivalents": sc(col(bs, "Cash And Cash Equivalents", "Cash Financial")),
119
- "short_term_investments": sc(col(bs, "Other Short Term Investments")),
120
- "receivables": sc(col(bs, "Accounts Receivable", "Other Receivables")),
121
  "inventory": sc(col(bs, "Inventory")),
122
- "other_current_assets": sc(col(bs, "Other Current Assets")),
123
- "total_current_assets": sc(col(bs, "Current Assets")),
124
- "ppe_net": sc(col(bs, "Net PPE")),
125
  "goodwill": sc(col(bs, "Goodwill")),
126
- "intangibles": sc(col(bs, "Other Intangible Assets")),
127
- "other_long_term_assets": sc(col(bs, "Other Non Current Assets")),
128
- "total_assets": sc(col(bs, "Total Assets")),
129
- "accounts_payable": sc(col(bs, "Accounts Payable", "Payables")),
130
- "short_term_debt": sc(col(bs, "Current Debt", "Current Debt And Capital Lease Obligation")),
131
- "other_current_liabilities": sc(col(bs, "Other Current Liabilities")),
132
- "total_current_liabilities": sc(col(bs, "Current Liabilities")),
133
- "long_term_debt": sc(col(bs, "Long Term Debt", "Long Term Debt And Capital Lease Obligation")),
134
- "other_long_term_liabilities":sc(col(bs, "Other Non Current Liabilities")),
135
- "total_liabilities": sc(col(bs, "Total Liabilities Net Minority Interest")),
136
- "total_equity": sc(col(bs, "Stockholders Equity", "Common Stock Equity")),
137
- "total_liab_and_equity": sc(col(bs, "Total Assets")),
138
  }
139
 
140
  cashflow = {
141
- "net_income": sc(col(cf, "Net Income From Continuing Operations")),
142
- "depreciation_amort": sc(col(cf, "Depreciation And Amortization", "Depreciation")),
143
- "stock_based_comp": [None] * n,
144
- "working_capital_changes": sc(col(cf, "Change In Working Capital")),
145
- "other_operating": sc(col(cf, "Other Non Cash Items")),
146
- "cash_from_operations": sc(col(cf, "Operating Cash Flow")),
147
- "capex": sc(col(cf, "Capital Expenditure", "Purchase Of PPE")),
148
- "acquisitions": [None] * n,
149
- "cash_from_investing": sc(col(cf, "Investing Cash Flow")),
150
- "debt_issuance_repayment": sc(col(cf, "Net Long Term Debt Issuance")),
151
- "stock_buybacks": sc(col(cf, "Net Common Stock Issuance")),
152
- "dividends_paid": sc(col(cf, "Cash Dividends Paid")),
153
- "cash_from_financing": sc(col(cf, "Financing Cash Flow")),
154
- "net_change_in_cash": sc(col(cf, "Changes In Cash")),
155
- "free_cash_flow": sc(col(cf, "Free Cash Flow")),
156
  }
157
 
158
  # Compute FCF if missing
@@ -162,8 +156,22 @@ def _fetch_yfinance(ticker: str, years: int, log_fn=None) -> Optional[Dict]:
162
  cap = cashflow["capex"][i]
163
  if cfo is not None and cap is not None:
164
  cashflow["free_cash_flow"][i] = round(cfo + cap, 2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
- log(f" βœ“ yfinance data ready ({n} years, {currency}, in {units})")
167
 
168
  return {
169
  "years": year_labels,
@@ -174,20 +182,12 @@ def _fetch_yfinance(ticker: str, years: int, log_fn=None) -> Optional[Dict]:
174
  "units": units,
175
  "units_label": units_label,
176
  "scale": scale,
177
- "source": "Yahoo Finance (yfinance)",
178
- "valuation_snapshot": {
179
- "pe_ttm": _s(info.get("trailingPE")),
180
- "pb": _s(info.get("priceToBook")),
181
- "ps": _s(info.get("priceToSalesTrailing12Months")),
182
- "ev_ebitda": _s(info.get("enterpriseToEbitda")),
183
- "ev_revenue": _s(info.get("enterpriseToRevenue")),
184
- "fcf_yield": None,
185
- "dividend_yield": _s(info.get("dividendYield")),
186
- },
187
  }
188
  except Exception as e:
189
  import traceback
190
- if log_fn: log_fn(f" βœ— yfinance error: {e}\n{traceback.format_exc()}")
191
  return None
192
 
193
 
@@ -712,26 +712,26 @@ def fetch_all(meta: CompanyMeta, years: int = DEFAULT_YEARS,
712
 
713
  # Fallback to yfinance if screener fails or yields nothing
714
  if not result or not result.get("years"):
715
- log(" β†’ screener.in failed. Falling back to yfinance ...")
716
- result = _fetch_yfinance(ticker, years, log_fn)
717
  if result and len(result.get("years", [])) < years:
718
- warnings.append(f"yfinance only provides {len(result['years'])} years of historical data for free.")
719
 
720
  # 2. Global/US Stocks
721
  else:
722
- # If requesting > 4 years, try stockanalysis.com first because yfinance limits to 4 years
723
  if years > 4:
724
  log(" β†’ Horizon > 4 years requested. Prioritizing stockanalysis.com for global stock ...")
725
  result = _scrape_stockanalysis(ticker, years, log_fn)
726
  if result:
727
  warnings.append("Data sourced from stockanalysis.com. Verify against company filings.")
728
 
729
- # Fallback to yfinance
730
  if not result or not result.get("years"):
731
- log(" β†’ Trying yfinance ...")
732
- result = _fetch_yfinance(ticker, years, log_fn)
733
  if result and len(result.get("years", [])) < years:
734
- warnings.append(f"yfinance only provides {len(result['years'])} years of historical data for free.")
735
 
736
  # Fallback to FMP
737
  if (not result or not result.get("years")) and FMP_API_KEY:
 
74
  for c in use_cols_asc: # iterate oldestβ†’newest
75
  if c in df.columns:
76
  v = df.at[k, c]
 
 
 
 
 
 
 
 
 
 
 
77
  if currency == "INR":
78
+ scale = 1e7
79
  units = "Crores"
80
  units_label = "INR Crores (β‚Ή Cr)"
81
  else:
82
+ scale = 1e6
83
  units = "Millions"
84
  units_label = f"{currency} Millions"
85
 
86
+ def col(df, *keys):
87
+ for k in keys:
88
+ if k in df.columns:
89
+ return df[k].tolist()
90
+ return [None] * len(df)
91
+
92
  def sc(lst):
93
+ return [round(v / scale, 2) if v is not None and not pd.isna(v) else None for v in lst]
 
94
 
95
  income = {
96
+ "revenue": sc(col(inc, "TotalRevenue", "OperatingRevenue")),
97
+ "cost_of_revenue": sc(col(inc, "CostOfRevenue", "ReconciledCostOfRevenue")),
98
+ "gross_profit": sc(col(inc, "GrossProfit")),
99
+ "operating_expenses": sc(col(inc, "OperatingExpense")),
100
+ "operating_income": sc(col(inc, "OperatingIncome", "EBIT")),
101
+ "ebitda": sc(col(inc, "EBITDA", "NormalizedEBITDA")),
102
+ "depreciation_amort": sc(col(inc, "ReconciledDepreciation", "DepreciationAndAmortization")),
103
+ "interest_expense": sc(col(inc, "InterestExpenseNonOperating", "InterestExpense")),
104
+ "pretax_income": sc(col(inc, "PretaxIncome")),
105
+ "tax_expense": sc(col(inc, "TaxProvision")),
106
+ "net_income": sc(col(inc, "NetIncome", "NetIncomeCommonStockholders")),
107
+ "diluted_eps": col(inc, "DilutedEPS"),
108
+ "diluted_shares": col(inc, "DilutedAverageShares"),
109
  }
110
 
111
  balance = {
112
+ "cash_and_equivalents": sc(col(bs, "CashAndCashEquivalents", "CashFinancial")),
113
+ "short_term_investments": sc(col(bs, "OtherShortTermInvestments")),
114
+ "receivables": sc(col(bs, "AccountsReceivable", "Receivables")),
115
  "inventory": sc(col(bs, "Inventory")),
116
+ "other_current_assets": sc(col(bs, "OtherCurrentAssets")),
117
+ "total_current_assets": sc(col(bs, "CurrentAssets")),
118
+ "ppe_net": sc(col(bs, "NetPPE")),
119
  "goodwill": sc(col(bs, "Goodwill")),
120
+ "intangibles": sc(col(bs, "OtherIntangibleAssets")),
121
+ "other_long_term_assets": sc(col(bs, "OtherNonCurrentAssets")),
122
+ "total_assets": sc(col(bs, "TotalAssets")),
123
+ "accounts_payable": sc(col(bs, "AccountsPayable", "Payables")),
124
+ "short_term_debt": sc(col(bs, "CurrentDebt", "CurrentDebtAndCapitalLeaseObligation")),
125
+ "other_current_liabilities": sc(col(bs, "OtherCurrentLiabilities")),
126
+ "total_current_liabilities": sc(col(bs, "CurrentLiabilities")),
127
+ "long_term_debt": sc(col(bs, "LongTermDebt", "LongTermDebtAndCapitalLeaseObligation")),
128
+ "other_long_term_liabilities":sc(col(bs, "OtherNonCurrentLiabilities")),
129
+ "total_liabilities": sc(col(bs, "TotalLiabilitiesNetMinorityInterest", "TotalLiabilities")),
130
+ "total_equity": sc(col(bs, "StockholdersEquity", "CommonStockEquity")),
131
+ "total_liab_and_equity": sc(col(bs, "TotalAssets")),
132
  }
133
 
134
  cashflow = {
135
+ "net_income": sc(col(cf, "NetIncomeFromContinuingOperations", "NetIncome")),
136
+ "depreciation_amort": sc(col(cf, "DepreciationAndAmortization", "Depreciation")),
137
+ "stock_based_comp": sc(col(cf, "StockBasedCompensation")),
138
+ "working_capital_changes": sc(col(cf, "ChangeInWorkingCapital")),
139
+ "other_operating": [None] * n,
140
+ "cash_from_operations": sc(col(cf, "OperatingCashFlow", "CashFlowFromContinuingOperatingActivities")),
141
+ "capex": sc(col(cf, "CapitalExpenditure", "PurchaseOfPPE")),
142
+ "acquisitions": sc(col(cf, "PurchaseOfBusiness")),
143
+ "cash_from_investing": sc(col(cf, "InvestingCashFlow", "CashFlowFromContinuingInvestingActivities")),
144
+ "debt_issuance_repayment": sc(col(cf, "NetLongTermDebtIssuance", "NetIssuancePaymentsOfDebt")),
145
+ "stock_buybacks": sc(col(cf, "NetCommonStockIssuance", "RepurchaseOfCapitalStock")),
146
+ "dividends_paid": sc(col(cf, "CashDividendsPaid")),
147
+ "cash_from_financing": sc(col(cf, "FinancingCashFlow", "CashFlowFromContinuingFinancingActivities")),
148
+ "net_change_in_cash": sc(col(cf, "ChangesInCash", "ChangeInCashSupplementalAsReported")),
149
+ "free_cash_flow": sc(col(cf, "FreeCashFlow")),
150
  }
151
 
152
  # Compute FCF if missing
 
156
  cap = cashflow["capex"][i]
157
  if cfo is not None and cap is not None:
158
  cashflow["free_cash_flow"][i] = round(cfo + cap, 2)
159
+
160
+ # Extract valuation snapshot and analyst targets
161
+ val_snap = {}
162
+ fd = t.financial_data
163
+ if fd and ticker in fd and isinstance(fd[ticker], dict):
164
+ val_snap["target_mean_price"] = fd[ticker].get("targetMeanPrice")
165
+ val_snap["target_high_price"] = fd[ticker].get("targetHighPrice")
166
+ val_snap["target_low_price"] = fd[ticker].get("targetLowPrice")
167
+ val_snap["wacc_estimate"] = 0.10 # default fallback
168
+
169
+ info = t.summary_detail
170
+ if info and ticker in info and isinstance(info[ticker], dict):
171
+ val_snap["pe_ttm"] = info[ticker].get("trailingPE")
172
+ val_snap["dividend_yield"] = info[ticker].get("dividendYield")
173
 
174
+ log(f" βœ“ yahooquery data ready ({n} years, {currency}, in {units})")
175
 
176
  return {
177
  "years": year_labels,
 
182
  "units": units,
183
  "units_label": units_label,
184
  "scale": scale,
185
+ "source": "Yahoo Finance (yahooquery)",
186
+ "valuation_snapshot": val_snap,
 
 
 
 
 
 
 
 
187
  }
188
  except Exception as e:
189
  import traceback
190
+ if log_fn: log_fn(f" βœ— yahooquery error: {e}\n{traceback.format_exc()}")
191
  return None
192
 
193
 
 
712
 
713
  # Fallback to yfinance if screener fails or yields nothing
714
  if not result or not result.get("years"):
715
+ log(" β†’ screener.in failed. Falling back to yahooquery ...")
716
+ result = _fetch_yahooquery(ticker, years, log_fn)
717
  if result and len(result.get("years", [])) < years:
718
+ warnings.append(f"yahooquery only provides {len(result['years'])} years of historical data.")
719
 
720
  # 2. Global/US Stocks
721
  else:
722
+ # If requesting > 4 years, try stockanalysis.com first because yahooquery limits to 4 years
723
  if years > 4:
724
  log(" β†’ Horizon > 4 years requested. Prioritizing stockanalysis.com for global stock ...")
725
  result = _scrape_stockanalysis(ticker, years, log_fn)
726
  if result:
727
  warnings.append("Data sourced from stockanalysis.com. Verify against company filings.")
728
 
729
+ # Fallback to yahooquery
730
  if not result or not result.get("years"):
731
+ log(" β†’ Trying yahooquery ...")
732
+ result = _fetch_yahooquery(ticker, years, log_fn)
733
  if result and len(result.get("years", [])) < years:
734
+ warnings.append(f"yahooquery only provides {len(result['years'])} years of historical data.")
735
 
736
  # Fallback to FMP
737
  if (not result or not result.get("years")) and FMP_API_KEY:
web_app/app.py CHANGED
@@ -215,7 +215,7 @@ with st.sidebar.expander("ChatGPT (OpenAI) β€” paid", expanded=False):
215
 
216
  st.sidebar.markdown("---")
217
  st.sidebar.markdown("<h3 style='color:#34D399;'>πŸ‘€ User Profile</h3>", unsafe_allow_html=True)
218
- username = st.sidebar.text_input("Username / Email", value="guest@local", help="Logged in the audit trail")
219
 
220
  # ── Main Dashboard Layout ──────────────────────────────────────────────
221
  st.markdown("<h1 class='dashboard-title'>πŸ“ˆ Financial Statement Analyzer</h1>", unsafe_allow_html=True)
@@ -345,7 +345,9 @@ with col2:
345
 
346
  # ── Analysis Runner ───────────────────────────────────────────────────
347
  if start_btn:
348
- if not ticker_input:
 
 
349
  st.error("Please enter a stock ticker or company name.")
350
  elif not (chk_excel or chk_word or chk_pptx):
351
  st.error("Please select at least one download format checkbox.")
 
215
 
216
  st.sidebar.markdown("---")
217
  st.sidebar.markdown("<h3 style='color:#34D399;'>πŸ‘€ User Profile</h3>", unsafe_allow_html=True)
218
+ username = st.sidebar.text_input("Username / Email", value="", placeholder="name@company.com", help="Mandatory for audit trail")
219
 
220
  # ── Main Dashboard Layout ──────────────────────────────────────────────
221
  st.markdown("<h1 class='dashboard-title'>πŸ“ˆ Financial Statement Analyzer</h1>", unsafe_allow_html=True)
 
345
 
346
  # ── Analysis Runner ───────────────────────────────────────────────────
347
  if start_btn:
348
+ if not username or "@" not in username:
349
+ st.error("Please enter a valid email address in the User Profile section before analyzing.")
350
+ elif not ticker_input:
351
  st.error("Please enter a stock ticker or company name.")
352
  elif not (chk_excel or chk_word or chk_pptx):
353
  st.error("Please select at least one download format checkbox.")
web_app/requirements.txt CHANGED
@@ -16,4 +16,5 @@ Pillow
16
  yfinance
17
  matplotlib
18
  seaborn
19
- huggingface_hub
 
 
16
  yfinance
17
  matplotlib
18
  seaborn
19
+ huggingface_hub
20
+ yahooquery