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

Upload folder using huggingface_hub

Browse files
Files changed (4) hide show
  1. builders/build_docx.py +410 -410
  2. builders/build_excel.py +587 -587
  3. pipeline/resolver.py +354 -346
  4. web_app/app.py +588 -588
builders/build_docx.py CHANGED
@@ -1,410 +1,410 @@
1
- #!/usr/bin/env python3
2
- """
3
- builders/build_docx.py β€” Enhanced Word report builder.
4
- Sections: Cover, Executive Summary, Company Profile, Industry Profile,
5
- SWOT, Industry Trends, Financial Trends, Ratios, DuPont, Peer Comparison,
6
- Key Insights, Risks, Things to Watch, Limitations, Sources.
7
- """
8
- import json, sys, datetime
9
- from pathlib import Path
10
- from typing import Dict, List, Optional
11
-
12
- try:
13
- from docx import Document
14
- from docx.shared import Pt, Cm, RGBColor, Inches
15
- from docx.enum.text import WD_ALIGN_PARAGRAPH
16
- from docx.enum.table import WD_ALIGN_VERTICAL, WD_TABLE_ALIGNMENT
17
- from docx.oxml.ns import qn
18
- from docx.oxml import OxmlElement
19
- except ImportError:
20
- print("ERROR: pip install python-docx", file=sys.stderr); sys.exit(1)
21
-
22
- # ── Colors ────────────────────────────────────────────────────────────────────
23
- NAVY = RGBColor(0x1F, 0x4E, 0x78)
24
- GRAY = RGBColor(0x59, 0x59, 0x59)
25
- GREEN_DARK = RGBColor(0x37, 0x5A, 0x3D)
26
-
27
- # ── Formatting helpers ────────────────────────────────────────────────────────
28
-
29
- def _shd(cell, hex_color):
30
- tcp = cell._tc.get_or_add_tcPr()
31
- s = OxmlElement("w:shd")
32
- s.set(qn("w:val"), "clear"); s.set(qn("w:color"), "auto"); s.set(qn("w:fill"), hex_color)
33
- tcp.append(s)
34
-
35
- def _borders(cell, color="BFBFBF", sz="4"):
36
- tcp = cell._tc.get_or_add_tcPr()
37
- tb = OxmlElement("w:tcBorders")
38
- for edge in ("top","left","bottom","right"):
39
- b = OxmlElement(f"w:{edge}")
40
- b.set(qn("w:val"),"single"); b.set(qn("w:sz"),sz); b.set(qn("w:color"),color)
41
- tb.append(b)
42
- tcp.append(tb)
43
-
44
- def _hdr_repeat(row):
45
- trp = row._tr.get_or_add_trPr()
46
- th = OxmlElement("w:tblHeader"); th.set(qn("w:val"),"true"); trp.append(th)
47
-
48
- def _add_heading(doc, text, level=1):
49
- sizes = {0:28, 1:18, 2:14, 3:12}
50
- p = doc.add_paragraph()
51
- r = p.add_run(text)
52
- r.font.size = Pt(sizes.get(level, 12)); r.font.bold = True
53
- r.font.color.rgb = NAVY; r.font.name = "Calibri"
54
- p.paragraph_format.space_before = Pt(12); p.paragraph_format.space_after = Pt(6)
55
- return p
56
-
57
- def _add_text(doc, text, size=11):
58
- if not text: return
59
- for block in [b.strip() for b in str(text).split("\n\n") if b.strip()]:
60
- p = doc.add_paragraph()
61
- r = p.add_run(block.replace("\n"," "))
62
- r.font.size = Pt(size); r.font.name = "Calibri"
63
- p.paragraph_format.space_after = Pt(8)
64
-
65
- def _fmt_v(v, kind="money", currency="INR", units="Crores"):
66
- if v is None: return "\u2014"
67
- try:
68
- sym = "\u20b9" if currency == "INR" else ("$" if currency == "USD" else "")
69
- if kind == "money":
70
- # Data is already scaled (Crores for INR, Millions for USD)
71
- av = abs(v)
72
- if currency == "INR":
73
- if av >= 100000: return f"{sym}{v/100000:,.2f} L.Cr"
74
- return f"{sym}{v:,.2f} Cr"
75
- else:
76
- if av >= 1000: return f"{sym}{v/1000:,.2f}B"
77
- return f"{sym}{v:,.2f}M"
78
- if kind == "money_dec": return f"{v:,.2f}"
79
- if kind == "pct": return f"{v*100:.1f}%"
80
- if kind in ("ratio", "days"): return f"{v:.2f}"
81
- return str(v)
82
- except: return str(v)
83
-
84
- def _page_break(doc):
85
- p = doc.add_paragraph(); p.add_run().add_break()
86
-
87
- # ── Table builder ─────────────────────────────────────────────────────────────
88
-
89
- def _table(doc, headers, rows, formats=None, currency="INR", units="Crores"):
90
- t = doc.add_table(rows=1+len(rows), cols=len(headers))
91
- t.alignment = WD_TABLE_ALIGNMENT.LEFT
92
- hc = t.rows[0].cells
93
- for i, h in enumerate(headers):
94
- hc[i].text = ""
95
- p = hc[i].paragraphs[0]
96
- r = p.add_run(str(h)); r.font.bold = True; r.font.color.rgb = RGBColor(0xFF,0xFF,0xFF)
97
- r.font.size = Pt(10); r.font.name = "Calibri"
98
- p.alignment = WD_ALIGN_PARAGRAPH.CENTER if i > 0 else WD_ALIGN_PARAGRAPH.LEFT
99
- _shd(hc[i], "1F4E78"); _borders(hc[i])
100
- _hdr_repeat(t.rows[0])
101
- for ri, row in enumerate(rows):
102
- bg = "F2F2F2" if ri % 2 else "FFFFFF"
103
- cells = t.rows[ri+1].cells
104
- for ci, val in enumerate(row):
105
- cells[ci].text = ""
106
- p = cells[ci].paragraphs[0]
107
- kind = formats[ci] if formats and ci < len(formats) else "money"
108
- if ci == 0:
109
- text = str(val) if val is not None else "β€”"
110
- else:
111
- text = _fmt_v(val, kind, currency, units)
112
- r = p.add_run(text); r.font.size = Pt(10); r.font.name = "Calibri"
113
- p.alignment = WD_ALIGN_PARAGRAPH.LEFT if ci == 0 else WD_ALIGN_PARAGRAPH.RIGHT
114
- _shd(cells[ci], bg); _borders(cells[ci])
115
- doc.add_paragraph().paragraph_format.space_after = Pt(6)
116
- return t
117
-
118
- def _gather(items, src, years):
119
- return [[label] + [(src.get(key) or [None]*len(years))[:len(years)][i]
120
- for i in range(len(years))] for label, key in items]
121
-
122
- # ── Section builders ──────────────────────────────────────────────────────────
123
-
124
- def _cover(doc, data):
125
- co = data.get("company", {})
126
- units_label = data.get("units_label", data.get("units", "Crores"))
127
- currency = data.get("currency", co.get("currency", "INR"))
128
- for _ in range(3): doc.add_paragraph()
129
- p = doc.add_paragraph()
130
- r = p.add_run("Financial Statement Analysis")
131
- r.font.size = Pt(32); r.font.bold = True; r.font.color.rgb = NAVY; r.font.name = "Calibri"
132
- p = doc.add_paragraph()
133
- r = p.add_run(co.get("name",""))
134
- r.font.size = Pt(24); r.font.color.rgb = GRAY; r.font.name = "Calibri"
135
- p = doc.add_paragraph()
136
- r = p.add_run(f"{co.get('ticker','')} \u00b7 {co.get('exchange','')} \u00b7 {co.get('industry','')}")
137
- r.font.size = Pt(13); r.font.color.rgb = GRAY; r.font.italic = True; r.font.name = "Calibri"
138
- for _ in range(6): doc.add_paragraph()
139
- for text in [
140
- f"Report date: {co.get('report_date', datetime.date.today().isoformat())}",
141
- f"Analysis window: {', '.join(data.get('years',[]))}",
142
- f"Reporting currency: {currency} | Units: {units_label}",
143
- f"Market cap: {co.get('market_cap', 'N/A')} | Share price: {co.get('share_price', 'N/A')}",
144
- f"AI models used: {', '.join(data.get('models_used',[]) or ['Data-only (no AI narrative)'])}",
145
- f"Data source: {data.get('source', 'Yahoo Finance (yfinance)')}",
146
- ]:
147
- p = doc.add_paragraph(); r = p.add_run(text)
148
- r.font.size = Pt(11); r.font.color.rgb = GRAY; r.font.name = "Calibri"
149
- _page_break(doc)
150
-
151
-
152
- def _swot_section(doc, data):
153
- _add_heading(doc, "SWOT Analysis", 1)
154
- swot = data.get("swot", {})
155
- configs = [
156
- ("Strengths", "strengths", "C6EFCE", GREEN_DARK),
157
- ("Weaknesses", "weaknesses", "FFC7CE", RGBColor(0xC0,0x00,0x00)),
158
- ("Opportunities", "opportunities", "DDEBF7", NAVY),
159
- ("Threats", "threats", "FFEB9C", RGBColor(0x7F,0x60,0x00)),
160
- ]
161
-
162
- parsed_any = any(len(swot.get(k, [])) > 0 for _, k, _, _ in configs)
163
-
164
- if not parsed_any and swot.get("raw"):
165
- for line in swot["raw"].split("\n"):
166
- if line.strip():
167
- p = doc.add_paragraph()
168
- r = p.add_run(line.strip())
169
- r.font.size = Pt(11); r.font.name = "Calibri"
170
- doc.add_paragraph()
171
- return
172
-
173
- t = doc.add_table(rows=2, cols=2)
174
- t.alignment = WD_TABLE_ALIGNMENT.LEFT
175
- idx = 0
176
- for row_i in range(2):
177
- for col_i in range(2):
178
- if idx >= len(configs): break
179
- label, key, bg, color = configs[idx]; idx += 1
180
- cell = t.cell(row_i, col_i)
181
- cell.text = ""
182
- p = cell.paragraphs[0]
183
- r = p.add_run(label); r.font.bold = True; r.font.size = Pt(12)
184
- r.font.color.rgb = color; r.font.name = "Calibri"
185
- _shd(cell, bg)
186
- for item in swot.get(key, []):
187
- p2 = cell.add_paragraph(f"β€’ {item}")
188
- for run in p2.runs: run.font.size = Pt(10); run.font.name = "Calibri"
189
- doc.add_paragraph()
190
-
191
-
192
- def _trends_section(doc, data):
193
- _add_heading(doc, "Multi-Year Financial Trends", 1)
194
- years = data.get("years", [])
195
- currency = data.get("currency", "INR")
196
- units_label = data.get("units_label", data.get("units", "Crores"))
197
- trend = data.get("trend_analysis", {})
198
-
199
- for section_title, prose_key, items, src_key, fmts in [
200
- ("Revenue & Profitability", "revenue_and_profitability",
201
- [("Revenue","revenue"),("Gross Profit","gross_profit"),("Operating Income","operating_income"),
202
- ("Net Income","net_income")],
203
- "income_statement", ["text"]+["money"]*len(years)),
204
- ("Balance Sheet Strength", "balance_sheet_strength",
205
- [("Total Assets","total_assets"),("Total Equity","total_equity"),
206
- ("Long-term Debt","long_term_debt"),("Current Ratio","current_ratio")],
207
- "balance_sheet", ["text"]+["money"]*len(years)),
208
- ("Cash Flow Generation", "cash_flow_generation",
209
- [("Cash from Operations","cash_from_operations"),("Capex","capex"),("Free Cash Flow","free_cash_flow")],
210
- "cash_flow", ["text"]+["money"]*len(years)),
211
- ]:
212
- _add_heading(doc, section_title, 2)
213
- _add_text(doc, trend.get(prose_key,""))
214
- src_data = data.get(src_key, {})
215
- if src_key == "balance_sheet":
216
- merged = dict(data.get("balance_sheet",{})); merged.update(data.get("ratios",{}))
217
- src_data = merged
218
- rows = _gather(items, src_data, years)
219
- _table(doc, [f"({units_label})"]+years, rows, fmts, currency, units_label)
220
-
221
-
222
- def _ratio_section(doc, data):
223
- _add_heading(doc, "Financial Ratio Analysis", 1)
224
- _add_text(doc, data.get("ratio_analysis_commentary",""))
225
- years = data.get("years", [])
226
- ratios = data.get("ratios", {})
227
- currency = data.get("currency", "INR")
228
- units_label = data.get("units_label", data.get("units", "Crores"))
229
- for sec_name, items in [
230
- ("Profitability", [("Gross Margin","gross_margin","pct"),("Operating Margin","operating_margin","pct"),
231
- ("Net Margin","net_margin","pct"),("ROE","roe","pct"),("ROIC","roic","pct")]),
232
- ("Liquidity & Solvency", [("Current Ratio","current_ratio","ratio"),("Quick Ratio","quick_ratio","ratio"),
233
- ("Debt-to-Equity","debt_to_equity","ratio"),("Net Debt/EBITDA","net_debt_to_ebitda","ratio")]),
234
- ("Efficiency", [("Asset Turnover","asset_turnover","ratio"),("DSO (days)","days_sales_outstanding","days"),
235
- ("DPO (days)","days_payables","days"),("CCC (days)","cash_conversion_cycle","days")]),
236
- ("Cash Flow Quality", [("OCF/Net Income","ocf_to_net_income","ratio"),("FCF Margin","fcf_margin","pct"),
237
- ("FCF Conversion","fcf_conversion","ratio")]),
238
- ]:
239
- _add_heading(doc, sec_name, 3)
240
- rows = []
241
- for label, key, kind in items:
242
- vals = (ratios.get(key,[]) + [None]*len(years))[:len(years)]
243
- rows.append([label] + list(vals))
244
- kinds = ["text"] + [items[0][2]]*len(years)
245
- _table(doc, ["Metric"]+years, rows, kinds, currency, units_label)
246
-
247
-
248
- def _dupont_section(doc, data):
249
- _add_heading(doc, "DuPont Analysis", 1)
250
- p = doc.add_paragraph()
251
- r = p.add_run("ROE = Net Margin \u00d7 Asset Turnover \u00d7 Equity Multiplier")
252
- r.font.size = Pt(11); r.font.bold = True; r.font.name = "Calibri"
253
- years = data.get("years", [])
254
- dp = data.get("dupont", {})
255
- currency = data.get("currency", "INR")
256
- rows = []
257
- for label, key, kind in [
258
- ("Net Margin","net_margin","pct"),("\u00d7 Asset Turnover","asset_turnover","ratio"),
259
- ("\u00d7 Equity Multiplier","equity_multiplier","ratio"),("= ROE (DuPont)","roe_computed","pct"),
260
- ("ROE (Reported)","roe_reported","pct"),
261
- ]:
262
- vals = (dp.get(key,[]) + [None]*len(years))[:len(years)]
263
- rows.append([label]+list(vals))
264
- _table(doc, ["Component"]+years, rows, ["text"]+["pct"]*len(years), currency)
265
-
266
-
267
- def _peer_section(doc, data):
268
- _add_heading(doc, "Peer Benchmarking", 1)
269
- _add_text(doc, data.get("peer_comparison_commentary",""))
270
- peers = data.get("peers", [])
271
- currency = data.get("currency", "INR")
272
- units_label = data.get("units_label", "Crores")
273
- co = data.get("company",{}); ratios = data.get("ratios",{})
274
- inc = data.get("income_statement",{}); val = data.get("valuation_snapshot",{})
275
- def last(arr):
276
- for v in reversed(arr or []):
277
- if v is not None: return v
278
- return None
279
- headers = ["Company","Revenue","Op Margin","Net Margin","ROIC","D/E","FCF Margin","P/E"]
280
- fmts = ["text","money","pct","pct","pct","ratio","pct","ratio"]
281
- rows = [[
282
- co.get("name",co.get("ticker",""))+" (Target)",
283
- last(inc.get("revenue",[])), last(ratios.get("operating_margin",[])),
284
- last(ratios.get("net_margin",[])), last(ratios.get("roic",[])),
285
- last(ratios.get("debt_to_equity",[])), last(ratios.get("fcf_margin",[])),
286
- val.get("pe_ttm"),
287
- ]]
288
- if not peers:
289
- doc.add_paragraph("Peer comparison data not available for this run.")
290
- for p in peers:
291
- rows.append([p.get("name",p.get("ticker","")), p.get("revenue"), p.get("operating_margin"),
292
- p.get("net_margin"), p.get("roic"), p.get("debt_to_equity"),
293
- p.get("fcf_margin"), p.get("pe_ttm")])
294
- if rows:
295
- _table(doc, headers, rows, fmts, currency, units_label)
296
-
297
-
298
- def _bullets(doc, items, heading=None, level=1):
299
- if heading: _add_heading(doc, heading, level)
300
- for item in items:
301
- if isinstance(item, dict):
302
- p = doc.add_paragraph(style="List Bullet")
303
- r1 = p.add_run(f"{item.get('category','')}: "); r1.font.bold=True; r1.font.size=Pt(11); r1.font.name="Calibri"
304
- r2 = p.add_run(item.get("description","")); r2.font.size=Pt(11); r2.font.name="Calibri"
305
- else:
306
- p = doc.add_paragraph(str(item), style="List Bullet")
307
- for r in p.runs: r.font.size=Pt(11); r.font.name="Calibri"
308
- p.paragraph_format.space_after = Pt(2)
309
-
310
-
311
- def _sources_section(doc, data):
312
- _add_heading(doc, "Sources", 1)
313
- for i, src in enumerate(data.get("sources",[]), 1):
314
- p = doc.add_paragraph()
315
- p.add_run(f"{i}. ").font.size = Pt(10)
316
- url_r = p.add_run(src.get("url",""))
317
- url_r.font.size=Pt(10); url_r.font.color.rgb=RGBColor(0x05,0x63,0xC1); url_r.font.name="Calibri"
318
- suffix = ""
319
- if src.get("description"): suffix += f" β€” {src['description']}"
320
- if src.get("retrieved"): suffix += f" (retrieved {src['retrieved']})"
321
- if suffix:
322
- sr = p.add_run(suffix); sr.font.size=Pt(10); sr.font.color.rgb=GRAY; sr.font.name="Calibri"
323
-
324
-
325
- def _disclaimer(doc):
326
- p = doc.add_paragraph()
327
- r = p.add_run(
328
- "Disclaimer: This report is generated from public financial data and AI analysis for informational "
329
- "purposes only. It is not investment advice. Verify all figures against primary sources before "
330
- "making any financial decisions."
331
- )
332
- r.font.size=Pt(9); r.font.italic=True; r.font.color.rgb=GRAY; r.font.name="Calibri"
333
- p.paragraph_format.space_before=Pt(12)
334
-
335
-
336
- # ── Orchestrator ──────────────────────────────────────────────────────────────
337
-
338
- def build_document(data: dict, output_path: str) -> None:
339
- doc = Document()
340
- for section in doc.sections:
341
- section.left_margin = section.right_margin = Cm(2.0)
342
- section.top_margin = section.bottom_margin = Cm(2.0)
343
-
344
- _cover(doc, data)
345
-
346
- # Executive Summary
347
- _add_heading(doc, "Executive Summary", 1)
348
- _add_text(doc, data.get("executive_summary",""))
349
-
350
- # Company & Industry Profile
351
- _add_heading(doc, "Company Profile", 1)
352
- _add_text(doc, data.get("company_overview",""))
353
-
354
- _add_heading(doc, "Industry Profile", 1)
355
- _add_text(doc, data.get("industry_profile",""))
356
-
357
- _add_heading(doc, "Industry Trends", 1)
358
- _add_text(doc, data.get("industry_trends",""))
359
-
360
- _swot_section(doc, data)
361
- _trends_section(doc, data)
362
-
363
- # Visualizations Section
364
- try:
365
- from builders.chart_builder import generate_all_charts
366
- charts = generate_all_charts(data)
367
- if any(charts.values()):
368
- _add_heading(doc, "Financial Visualizations", 1)
369
- if charts.get("revenue_net_income"):
370
- _add_heading(doc, "Revenue & Net Income Trend", 2)
371
- doc.add_picture(charts["revenue_net_income"], width=Inches(6.0))
372
- if charts.get("margins"):
373
- _add_heading(doc, "Profitability Margins", 2)
374
- doc.add_picture(charts["margins"], width=Inches(6.0))
375
- if charts.get("cash_flow"):
376
- _add_heading(doc, "Cash Flow & CAPEX", 2)
377
- doc.add_picture(charts["cash_flow"], width=Inches(6.0))
378
- except Exception as e:
379
- print(f"Warning: Could not generate DOCX charts: {e}", file=sys.stderr)
380
-
381
- _ratio_section(doc, data)
382
- _dupont_section(doc, data)
383
- _peer_section(doc, data)
384
-
385
- if data.get("key_insights"):
386
- _bullets(doc, data["key_insights"], "Key Insights", 1)
387
- if data.get("risks"):
388
- _bullets(doc, data["risks"], "Risks", 1)
389
- if data.get("things_to_watch"):
390
- _bullets(doc, data["things_to_watch"], "Things to Watch", 1)
391
- if data.get("limitations_and_caveats"):
392
- _bullets(doc, data["limitations_and_caveats"], "Limitations & Caveats", 1)
393
-
394
- _sources_section(doc, data)
395
- _disclaimer(doc)
396
-
397
- Path(output_path).parent.mkdir(parents=True, exist_ok=True)
398
- doc.save(output_path)
399
-
400
-
401
- if __name__ == "__main__":
402
- import argparse
403
- p = argparse.ArgumentParser()
404
- p.add_argument("--input","-i",required=True)
405
- p.add_argument("--output","-o",required=True)
406
- args = p.parse_args()
407
- with open(args.input, encoding="utf-8") as f:
408
- data = json.load(f)
409
- build_document(data, args.output)
410
- print(f"Wrote: {args.output}")
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ builders/build_docx.py β€” Enhanced Word report builder.
4
+ Sections: Cover, Executive Summary, Company Profile, Industry Profile,
5
+ SWOT, Industry Trends, Financial Trends, Ratios, DuPont, Peer Comparison,
6
+ Key Insights, Risks, Things to Watch, Limitations, Sources.
7
+ """
8
+ import json, sys, datetime
9
+ from pathlib import Path
10
+ from typing import Dict, List, Optional
11
+
12
+ try:
13
+ from docx import Document
14
+ from docx.shared import Pt, Cm, RGBColor, Inches
15
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
16
+ from docx.enum.table import WD_ALIGN_VERTICAL, WD_TABLE_ALIGNMENT
17
+ from docx.oxml.ns import qn
18
+ from docx.oxml import OxmlElement
19
+ except ImportError:
20
+ print("ERROR: pip install python-docx", file=sys.stderr); sys.exit(1)
21
+
22
+ # ── Colors ────────────────────────────────────────────────────────────────────
23
+ NAVY = RGBColor(0x1F, 0x4E, 0x78)
24
+ GRAY = RGBColor(0x59, 0x59, 0x59)
25
+ GREEN_DARK = RGBColor(0x37, 0x5A, 0x3D)
26
+
27
+ # ── Formatting helpers ────────────────────────────────────────────────────────
28
+
29
+ def _shd(cell, hex_color):
30
+ tcp = cell._tc.get_or_add_tcPr()
31
+ s = OxmlElement("w:shd")
32
+ s.set(qn("w:val"), "clear"); s.set(qn("w:color"), "auto"); s.set(qn("w:fill"), hex_color)
33
+ tcp.append(s)
34
+
35
+ def _borders(cell, color="BFBFBF", sz="4"):
36
+ tcp = cell._tc.get_or_add_tcPr()
37
+ tb = OxmlElement("w:tcBorders")
38
+ for edge in ("top","left","bottom","right"):
39
+ b = OxmlElement(f"w:{edge}")
40
+ b.set(qn("w:val"),"single"); b.set(qn("w:sz"),sz); b.set(qn("w:color"),color)
41
+ tb.append(b)
42
+ tcp.append(tb)
43
+
44
+ def _hdr_repeat(row):
45
+ trp = row._tr.get_or_add_trPr()
46
+ th = OxmlElement("w:tblHeader"); th.set(qn("w:val"),"true"); trp.append(th)
47
+
48
+ def _add_heading(doc, text, level=1):
49
+ sizes = {0:28, 1:18, 2:14, 3:12}
50
+ p = doc.add_paragraph()
51
+ r = p.add_run(text)
52
+ r.font.size = Pt(sizes.get(level, 12)); r.font.bold = True
53
+ r.font.color.rgb = NAVY; r.font.name = "Calibri"
54
+ p.paragraph_format.space_before = Pt(12); p.paragraph_format.space_after = Pt(6)
55
+ return p
56
+
57
+ def _add_text(doc, text, size=11):
58
+ if not text: return
59
+ for block in [b.strip() for b in str(text).split("\n\n") if b.strip()]:
60
+ p = doc.add_paragraph()
61
+ r = p.add_run(block.replace("\n"," "))
62
+ r.font.size = Pt(size); r.font.name = "Calibri"
63
+ p.paragraph_format.space_after = Pt(8)
64
+
65
+ def _fmt_v(v, kind="money", currency="INR", units="Crores"):
66
+ if v is None: return "\u2014"
67
+ try:
68
+ sym = "\u20b9" if currency == "INR" else ("$" if currency == "USD" else "")
69
+ if kind == "money":
70
+ # Data is already scaled (Crores for INR, Millions for USD)
71
+ av = abs(v)
72
+ if currency == "INR":
73
+ if av >= 100000: return f"{sym}{v/100000:,.2f} L.Cr"
74
+ return f"{sym}{v:,.2f} Cr"
75
+ else:
76
+ if av >= 1000: return f"{sym}{v/1000:,.2f}B"
77
+ return f"{sym}{v:,.2f}M"
78
+ if kind == "money_dec": return f"{v:,.2f}"
79
+ if kind == "pct": return f"{v*100:.1f}%"
80
+ if kind in ("ratio", "days"): return f"{v:.2f}"
81
+ return str(v)
82
+ except: return str(v)
83
+
84
+ def _page_break(doc):
85
+ p = doc.add_paragraph(); p.add_run().add_break()
86
+
87
+ # ── Table builder ─────────────────────────────────────────────────────────────
88
+
89
+ def _table(doc, headers, rows, formats=None, currency="INR", units="Crores"):
90
+ t = doc.add_table(rows=1+len(rows), cols=len(headers))
91
+ t.alignment = WD_TABLE_ALIGNMENT.LEFT
92
+ hc = t.rows[0].cells
93
+ for i, h in enumerate(headers):
94
+ hc[i].text = ""
95
+ p = hc[i].paragraphs[0]
96
+ r = p.add_run(str(h)); r.font.bold = True; r.font.color.rgb = RGBColor(0xFF,0xFF,0xFF)
97
+ r.font.size = Pt(10); r.font.name = "Calibri"
98
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER if i > 0 else WD_ALIGN_PARAGRAPH.LEFT
99
+ _shd(hc[i], "1F4E78"); _borders(hc[i])
100
+ _hdr_repeat(t.rows[0])
101
+ for ri, row in enumerate(rows):
102
+ bg = "F2F2F2" if ri % 2 else "FFFFFF"
103
+ cells = t.rows[ri+1].cells
104
+ for ci, val in enumerate(row):
105
+ cells[ci].text = ""
106
+ p = cells[ci].paragraphs[0]
107
+ kind = formats[ci] if formats and ci < len(formats) else "money"
108
+ if ci == 0:
109
+ text = str(val) if val is not None else "β€”"
110
+ else:
111
+ text = _fmt_v(val, kind, currency, units)
112
+ r = p.add_run(text); r.font.size = Pt(10); r.font.name = "Calibri"
113
+ p.alignment = WD_ALIGN_PARAGRAPH.LEFT if ci == 0 else WD_ALIGN_PARAGRAPH.RIGHT
114
+ _shd(cells[ci], bg); _borders(cells[ci])
115
+ doc.add_paragraph().paragraph_format.space_after = Pt(6)
116
+ return t
117
+
118
+ def _gather(items, src, years):
119
+ return [[label] + [(src.get(key) or [None]*len(years))[:len(years)][i]
120
+ for i in range(len(years))] for label, key in items]
121
+
122
+ # ── Section builders ──────────────────────────────────────────────────────────
123
+
124
+ def _cover(doc, data):
125
+ co = data.get("company", {})
126
+ units_label = data.get("units_label", data.get("units", "Crores"))
127
+ currency = data.get("currency", co.get("currency", "INR"))
128
+ for _ in range(3): doc.add_paragraph()
129
+ p = doc.add_paragraph()
130
+ r = p.add_run("Financial Statement Analysis")
131
+ r.font.size = Pt(32); r.font.bold = True; r.font.color.rgb = NAVY; r.font.name = "Calibri"
132
+ p = doc.add_paragraph()
133
+ r = p.add_run(co.get("name",""))
134
+ r.font.size = Pt(24); r.font.color.rgb = GRAY; r.font.name = "Calibri"
135
+ p = doc.add_paragraph()
136
+ r = p.add_run(f"{co.get('ticker','')} \u00b7 {co.get('exchange','')} \u00b7 {co.get('industry','')}")
137
+ r.font.size = Pt(13); r.font.color.rgb = GRAY; r.font.italic = True; r.font.name = "Calibri"
138
+ for _ in range(6): doc.add_paragraph()
139
+ for text in [
140
+ f"Report date: {co.get('report_date', datetime.date.today().isoformat())}",
141
+ f"Analysis window: {', '.join(data.get('years',[]))}",
142
+ f"Reporting currency: {currency} | Units: {units_label}",
143
+ f"Market cap: {co.get('market_cap', 'N/A')} | Share price: {co.get('share_price', 'N/A')}",
144
+ f"AI models used: {', '.join(data.get('models_used',[]) or ['Data-only (no AI narrative)'])}",
145
+ f"Data source: {data.get('source', 'Yahoo Finance (yahooquery)')}",
146
+ ]:
147
+ p = doc.add_paragraph(); r = p.add_run(text)
148
+ r.font.size = Pt(11); r.font.color.rgb = GRAY; r.font.name = "Calibri"
149
+ _page_break(doc)
150
+
151
+
152
+ def _swot_section(doc, data):
153
+ _add_heading(doc, "SWOT Analysis", 1)
154
+ swot = data.get("swot", {})
155
+ configs = [
156
+ ("Strengths", "strengths", "C6EFCE", GREEN_DARK),
157
+ ("Weaknesses", "weaknesses", "FFC7CE", RGBColor(0xC0,0x00,0x00)),
158
+ ("Opportunities", "opportunities", "DDEBF7", NAVY),
159
+ ("Threats", "threats", "FFEB9C", RGBColor(0x7F,0x60,0x00)),
160
+ ]
161
+
162
+ parsed_any = any(len(swot.get(k, [])) > 0 for _, k, _, _ in configs)
163
+
164
+ if not parsed_any and swot.get("raw"):
165
+ for line in swot["raw"].split("\n"):
166
+ if line.strip():
167
+ p = doc.add_paragraph()
168
+ r = p.add_run(line.strip())
169
+ r.font.size = Pt(11); r.font.name = "Calibri"
170
+ doc.add_paragraph()
171
+ return
172
+
173
+ t = doc.add_table(rows=2, cols=2)
174
+ t.alignment = WD_TABLE_ALIGNMENT.LEFT
175
+ idx = 0
176
+ for row_i in range(2):
177
+ for col_i in range(2):
178
+ if idx >= len(configs): break
179
+ label, key, bg, color = configs[idx]; idx += 1
180
+ cell = t.cell(row_i, col_i)
181
+ cell.text = ""
182
+ p = cell.paragraphs[0]
183
+ r = p.add_run(label); r.font.bold = True; r.font.size = Pt(12)
184
+ r.font.color.rgb = color; r.font.name = "Calibri"
185
+ _shd(cell, bg)
186
+ for item in swot.get(key, []):
187
+ p2 = cell.add_paragraph(f"β€’ {item}")
188
+ for run in p2.runs: run.font.size = Pt(10); run.font.name = "Calibri"
189
+ doc.add_paragraph()
190
+
191
+
192
+ def _trends_section(doc, data):
193
+ _add_heading(doc, "Multi-Year Financial Trends", 1)
194
+ years = data.get("years", [])
195
+ currency = data.get("currency", "INR")
196
+ units_label = data.get("units_label", data.get("units", "Crores"))
197
+ trend = data.get("trend_analysis", {})
198
+
199
+ for section_title, prose_key, items, src_key, fmts in [
200
+ ("Revenue & Profitability", "revenue_and_profitability",
201
+ [("Revenue","revenue"),("Gross Profit","gross_profit"),("Operating Income","operating_income"),
202
+ ("Net Income","net_income")],
203
+ "income_statement", ["text"]+["money"]*len(years)),
204
+ ("Balance Sheet Strength", "balance_sheet_strength",
205
+ [("Total Assets","total_assets"),("Total Equity","total_equity"),
206
+ ("Long-term Debt","long_term_debt"),("Current Ratio","current_ratio")],
207
+ "balance_sheet", ["text"]+["money"]*len(years)),
208
+ ("Cash Flow Generation", "cash_flow_generation",
209
+ [("Cash from Operations","cash_from_operations"),("Capex","capex"),("Free Cash Flow","free_cash_flow")],
210
+ "cash_flow", ["text"]+["money"]*len(years)),
211
+ ]:
212
+ _add_heading(doc, section_title, 2)
213
+ _add_text(doc, trend.get(prose_key,""))
214
+ src_data = data.get(src_key, {})
215
+ if src_key == "balance_sheet":
216
+ merged = dict(data.get("balance_sheet",{})); merged.update(data.get("ratios",{}))
217
+ src_data = merged
218
+ rows = _gather(items, src_data, years)
219
+ _table(doc, [f"({units_label})"]+years, rows, fmts, currency, units_label)
220
+
221
+
222
+ def _ratio_section(doc, data):
223
+ _add_heading(doc, "Financial Ratio Analysis", 1)
224
+ _add_text(doc, data.get("ratio_analysis_commentary",""))
225
+ years = data.get("years", [])
226
+ ratios = data.get("ratios", {})
227
+ currency = data.get("currency", "INR")
228
+ units_label = data.get("units_label", data.get("units", "Crores"))
229
+ for sec_name, items in [
230
+ ("Profitability", [("Gross Margin","gross_margin","pct"),("Operating Margin","operating_margin","pct"),
231
+ ("Net Margin","net_margin","pct"),("ROE","roe","pct"),("ROIC","roic","pct")]),
232
+ ("Liquidity & Solvency", [("Current Ratio","current_ratio","ratio"),("Quick Ratio","quick_ratio","ratio"),
233
+ ("Debt-to-Equity","debt_to_equity","ratio"),("Net Debt/EBITDA","net_debt_to_ebitda","ratio")]),
234
+ ("Efficiency", [("Asset Turnover","asset_turnover","ratio"),("DSO (days)","days_sales_outstanding","days"),
235
+ ("DPO (days)","days_payables","days"),("CCC (days)","cash_conversion_cycle","days")]),
236
+ ("Cash Flow Quality", [("OCF/Net Income","ocf_to_net_income","ratio"),("FCF Margin","fcf_margin","pct"),
237
+ ("FCF Conversion","fcf_conversion","ratio")]),
238
+ ]:
239
+ _add_heading(doc, sec_name, 3)
240
+ rows = []
241
+ for label, key, kind in items:
242
+ vals = (ratios.get(key,[]) + [None]*len(years))[:len(years)]
243
+ rows.append([label] + list(vals))
244
+ kinds = ["text"] + [items[0][2]]*len(years)
245
+ _table(doc, ["Metric"]+years, rows, kinds, currency, units_label)
246
+
247
+
248
+ def _dupont_section(doc, data):
249
+ _add_heading(doc, "DuPont Analysis", 1)
250
+ p = doc.add_paragraph()
251
+ r = p.add_run("ROE = Net Margin \u00d7 Asset Turnover \u00d7 Equity Multiplier")
252
+ r.font.size = Pt(11); r.font.bold = True; r.font.name = "Calibri"
253
+ years = data.get("years", [])
254
+ dp = data.get("dupont", {})
255
+ currency = data.get("currency", "INR")
256
+ rows = []
257
+ for label, key, kind in [
258
+ ("Net Margin","net_margin","pct"),("\u00d7 Asset Turnover","asset_turnover","ratio"),
259
+ ("\u00d7 Equity Multiplier","equity_multiplier","ratio"),("= ROE (DuPont)","roe_computed","pct"),
260
+ ("ROE (Reported)","roe_reported","pct"),
261
+ ]:
262
+ vals = (dp.get(key,[]) + [None]*len(years))[:len(years)]
263
+ rows.append([label]+list(vals))
264
+ _table(doc, ["Component"]+years, rows, ["text"]+["pct"]*len(years), currency)
265
+
266
+
267
+ def _peer_section(doc, data):
268
+ _add_heading(doc, "Peer Benchmarking", 1)
269
+ _add_text(doc, data.get("peer_comparison_commentary",""))
270
+ peers = data.get("peers", [])
271
+ currency = data.get("currency", "INR")
272
+ units_label = data.get("units_label", "Crores")
273
+ co = data.get("company",{}); ratios = data.get("ratios",{})
274
+ inc = data.get("income_statement",{}); val = data.get("valuation_snapshot",{})
275
+ def last(arr):
276
+ for v in reversed(arr or []):
277
+ if v is not None: return v
278
+ return None
279
+ headers = ["Company","Revenue","Op Margin","Net Margin","ROIC","D/E","FCF Margin","P/E"]
280
+ fmts = ["text","money","pct","pct","pct","ratio","pct","ratio"]
281
+ rows = [[
282
+ co.get("name",co.get("ticker",""))+" (Target)",
283
+ last(inc.get("revenue",[])), last(ratios.get("operating_margin",[])),
284
+ last(ratios.get("net_margin",[])), last(ratios.get("roic",[])),
285
+ last(ratios.get("debt_to_equity",[])), last(ratios.get("fcf_margin",[])),
286
+ val.get("pe_ttm"),
287
+ ]]
288
+ if not peers:
289
+ doc.add_paragraph("Peer comparison data not available for this run.")
290
+ for p in peers:
291
+ rows.append([p.get("name",p.get("ticker","")), p.get("revenue"), p.get("operating_margin"),
292
+ p.get("net_margin"), p.get("roic"), p.get("debt_to_equity"),
293
+ p.get("fcf_margin"), p.get("pe_ttm")])
294
+ if rows:
295
+ _table(doc, headers, rows, fmts, currency, units_label)
296
+
297
+
298
+ def _bullets(doc, items, heading=None, level=1):
299
+ if heading: _add_heading(doc, heading, level)
300
+ for item in items:
301
+ if isinstance(item, dict):
302
+ p = doc.add_paragraph(style="List Bullet")
303
+ r1 = p.add_run(f"{item.get('category','')}: "); r1.font.bold=True; r1.font.size=Pt(11); r1.font.name="Calibri"
304
+ r2 = p.add_run(item.get("description","")); r2.font.size=Pt(11); r2.font.name="Calibri"
305
+ else:
306
+ p = doc.add_paragraph(str(item), style="List Bullet")
307
+ for r in p.runs: r.font.size=Pt(11); r.font.name="Calibri"
308
+ p.paragraph_format.space_after = Pt(2)
309
+
310
+
311
+ def _sources_section(doc, data):
312
+ _add_heading(doc, "Sources", 1)
313
+ for i, src in enumerate(data.get("sources",[]), 1):
314
+ p = doc.add_paragraph()
315
+ p.add_run(f"{i}. ").font.size = Pt(10)
316
+ url_r = p.add_run(src.get("url",""))
317
+ url_r.font.size=Pt(10); url_r.font.color.rgb=RGBColor(0x05,0x63,0xC1); url_r.font.name="Calibri"
318
+ suffix = ""
319
+ if src.get("description"): suffix += f" β€” {src['description']}"
320
+ if src.get("retrieved"): suffix += f" (retrieved {src['retrieved']})"
321
+ if suffix:
322
+ sr = p.add_run(suffix); sr.font.size=Pt(10); sr.font.color.rgb=GRAY; sr.font.name="Calibri"
323
+
324
+
325
+ def _disclaimer(doc):
326
+ p = doc.add_paragraph()
327
+ r = p.add_run(
328
+ "Disclaimer: This report is generated from public financial data and AI analysis for informational "
329
+ "purposes only. It is not investment advice. Verify all figures against primary sources before "
330
+ "making any financial decisions."
331
+ )
332
+ r.font.size=Pt(9); r.font.italic=True; r.font.color.rgb=GRAY; r.font.name="Calibri"
333
+ p.paragraph_format.space_before=Pt(12)
334
+
335
+
336
+ # ── Orchestrator ──────────────────────────────────────────────────────────────
337
+
338
+ def build_document(data: dict, output_path: str) -> None:
339
+ doc = Document()
340
+ for section in doc.sections:
341
+ section.left_margin = section.right_margin = Cm(2.0)
342
+ section.top_margin = section.bottom_margin = Cm(2.0)
343
+
344
+ _cover(doc, data)
345
+
346
+ # Executive Summary
347
+ _add_heading(doc, "Executive Summary", 1)
348
+ _add_text(doc, data.get("executive_summary",""))
349
+
350
+ # Company & Industry Profile
351
+ _add_heading(doc, "Company Profile", 1)
352
+ _add_text(doc, data.get("company_overview",""))
353
+
354
+ _add_heading(doc, "Industry Profile", 1)
355
+ _add_text(doc, data.get("industry_profile",""))
356
+
357
+ _add_heading(doc, "Industry Trends", 1)
358
+ _add_text(doc, data.get("industry_trends",""))
359
+
360
+ _swot_section(doc, data)
361
+ _trends_section(doc, data)
362
+
363
+ # Visualizations Section
364
+ try:
365
+ from builders.chart_builder import generate_all_charts
366
+ charts = generate_all_charts(data)
367
+ if any(charts.values()):
368
+ _add_heading(doc, "Financial Visualizations", 1)
369
+ if charts.get("revenue_net_income"):
370
+ _add_heading(doc, "Revenue & Net Income Trend", 2)
371
+ doc.add_picture(charts["revenue_net_income"], width=Inches(6.0))
372
+ if charts.get("margins"):
373
+ _add_heading(doc, "Profitability Margins", 2)
374
+ doc.add_picture(charts["margins"], width=Inches(6.0))
375
+ if charts.get("cash_flow"):
376
+ _add_heading(doc, "Cash Flow & CAPEX", 2)
377
+ doc.add_picture(charts["cash_flow"], width=Inches(6.0))
378
+ except Exception as e:
379
+ print(f"Warning: Could not generate DOCX charts: {e}", file=sys.stderr)
380
+
381
+ _ratio_section(doc, data)
382
+ _dupont_section(doc, data)
383
+ _peer_section(doc, data)
384
+
385
+ if data.get("key_insights"):
386
+ _bullets(doc, data["key_insights"], "Key Insights", 1)
387
+ if data.get("risks"):
388
+ _bullets(doc, data["risks"], "Risks", 1)
389
+ if data.get("things_to_watch"):
390
+ _bullets(doc, data["things_to_watch"], "Things to Watch", 1)
391
+ if data.get("limitations_and_caveats"):
392
+ _bullets(doc, data["limitations_and_caveats"], "Limitations & Caveats", 1)
393
+
394
+ _sources_section(doc, data)
395
+ _disclaimer(doc)
396
+
397
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
398
+ doc.save(output_path)
399
+
400
+
401
+ if __name__ == "__main__":
402
+ import argparse
403
+ p = argparse.ArgumentParser()
404
+ p.add_argument("--input","-i",required=True)
405
+ p.add_argument("--output","-o",required=True)
406
+ args = p.parse_args()
407
+ with open(args.input, encoding="utf-8") as f:
408
+ data = json.load(f)
409
+ build_document(data, args.output)
410
+ print(f"Wrote: {args.output}")
builders/build_excel.py CHANGED
@@ -1,587 +1,587 @@
1
- #!/usr/bin/env python3
2
- """
3
- builders/build_excel.py β€” Enhanced Excel workbook builder.
4
- Reads the skill's original builder and adds DuPont, SWOT, and Indian unit support.
5
- """
6
- import json, sys, datetime
7
- from pathlib import Path
8
-
9
- try:
10
- from openpyxl import Workbook
11
- from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
12
- from openpyxl.utils import get_column_letter
13
- from openpyxl.chart import BarChart, LineChart, Reference
14
- except ImportError:
15
- print("ERROR: pip install openpyxl", file=sys.stderr); sys.exit(1)
16
-
17
- # ── Style constants ───────────────────────────────────────────────────────────
18
- HDR_FILL = PatternFill("solid", fgColor="1F4E78")
19
- HDR_FONT = Font(name="Calibri", size=11, bold=True, color="FFFFFF")
20
- SEC_FILL = PatternFill("solid", fgColor="D9E1F2")
21
- SEC_FONT = Font(name="Calibri", size=11, bold=True, color="1F4E78")
22
- LBL_FONT = Font(name="Calibri", size=11)
23
- TTL_FONT = Font(name="Calibri", size=16, bold=True, color="1F4E78")
24
- DAT_FONT = Font(name="Calibri", size=11)
25
- GRN_FILL = PatternFill("solid", fgColor="C6EFCE")
26
- RED_FILL = PatternFill("solid", fgColor="FFC7CE")
27
- YLW_FILL = PatternFill("solid", fgColor="FFEB9C")
28
-
29
- THIN = Border(
30
- left=Side(style="thin", color="BFBFBF"), right=Side(style="thin", color="BFBFBF"),
31
- top=Side(style="thin", color="BFBFBF"), bottom=Side(style="thin", color="BFBFBF"),
32
- )
33
- CENTER = Alignment(horizontal="center", vertical="center", wrap_text=True)
34
- LEFT = Alignment(horizontal="left", vertical="center", wrap_text=True)
35
- RIGHT = Alignment(horizontal="right", vertical="center")
36
-
37
- FMT_MONEY = '#,##0;(#,##0);"β€”"'
38
- FMT_DEC = '#,##0.00;(#,##0.00);"β€”"'
39
- FMT_PCT = '0.0%;(0.0%);"β€”"'
40
- FMT_RATIO = '0.00;(0.00);"β€”"'
41
- FMT_DAYS = '0.0;"β€”"'
42
-
43
- # ── Helpers ───────────────────────────────────────────────────────────────────
44
-
45
- def _safe(arr, i, default=None):
46
- try: return arr[i] if arr and arr[i] is not None else default
47
- except: return default
48
-
49
- def _hdr(ws, row, cols, start=1):
50
- for i, h in enumerate(cols):
51
- c = ws.cell(row=row, column=start+i, value=h)
52
- c.font, c.fill, c.alignment, c.border = HDR_FONT, HDR_FILL, CENTER, THIN
53
-
54
- def _sec(ws, row, label, ncols, start=1):
55
- ws.cell(row=row, column=start, value=label).font = SEC_FONT
56
- ws.cell(row=row, column=start).fill = SEC_FILL
57
- ws.cell(row=row, column=start).alignment = LEFT
58
- for col in range(start+1, start+ncols):
59
- ws.cell(row=row, column=col).fill = SEC_FILL
60
-
61
- def _data_row(ws, row, label, values, fmt=FMT_MONEY, start=1):
62
- c = ws.cell(row=row, column=start, value=label)
63
- c.font, c.alignment, c.border = LBL_FONT, LEFT, THIN
64
- for i, v in enumerate(values):
65
- cell = ws.cell(row=row, column=start+1+i, value=v if v is not None else "β€”")
66
- cell.font, cell.alignment, cell.border = DAT_FONT, RIGHT, THIN
67
- if v is not None and isinstance(v, (int, float)):
68
- cell.number_format = fmt
69
-
70
- def _title(ws, title, subtitle=None):
71
- ws.cell(row=1, column=1, value=title).font = TTL_FONT
72
- if subtitle:
73
- ws.cell(row=2, column=1, value=subtitle).font = Font(name="Calibri", size=11, italic=True, color="595959")
74
-
75
- def _widths(ws, widths):
76
- for i, w in enumerate(widths):
77
- ws.column_dimensions[get_column_letter(i+1)].width = w
78
-
79
- def _freeze(ws, hdr_row):
80
- ws.freeze_panes = ws.cell(row=hdr_row+1, column=2)
81
-
82
- def _last(arr):
83
- if not arr: return None
84
- for v in reversed(arr):
85
- if v is not None: return v
86
- return None
87
-
88
- # ── Statement section helper ──────────────────────────────────────────────────
89
-
90
- def _stmt_section(ws, row, title, items, data_dict, years, fmt=FMT_MONEY):
91
- _sec(ws, row, title, 1+len(years))
92
- row += 1
93
- for label, key in items:
94
- vals = data_dict.get(key, [None]*len(years))
95
- vals = (vals + [None]*len(years))[:len(years)]
96
- _data_row(ws, row, label, vals, fmt)
97
- row += 1
98
- return row
99
-
100
- # ── Sheet builders ────────────────────────────────────────────────────────────
101
-
102
- def _summary_sheet(wb, data):
103
- ws = wb.create_sheet("Summary")
104
- co = data.get("company", {})
105
- units_label = data.get("units_label", data.get("units", "Crores"))
106
- currency = data.get("currency", co.get("currency", "INR"))
107
- _title(ws, f"{co.get('name','')} ({co.get('ticker','')}) β€” Financial Analysis",
108
- f"Report date: {co.get('report_date', datetime.date.today().isoformat())}")
109
- r = 4
110
- for label, val in [
111
- ("Company", co.get("name","")),
112
- ("Ticker", co.get("ticker","")),
113
- ("Exchange", co.get("exchange","")),
114
- ("Industry", co.get("industry","")),
115
- ("Sector", co.get("sector","")),
116
- ("Currency", currency),
117
- ("Reporting Units", units_label),
118
- ("Fiscal Year End", co.get("fiscal_year_end","")),
119
- ("Analysis Period", f"{len(data.get('years',[]))} years ({', '.join(data.get('years',[]))})"),
120
- ("Share Price", co.get("share_price")),
121
- ("Market Cap", co.get("market_cap")),
122
- ("Data Source", data.get("source", "Yahoo Finance (yfinance)")),
123
- ("Models Used", ", ".join(data.get("models_used", []) or ["None"])),
124
- ("Warnings", " | ".join(data.get("warnings", []))),
125
- ]:
126
- ws.cell(row=r, column=1, value=label).font = LBL_FONT
127
- ws.cell(row=r, column=2, value=val).font = DAT_FONT
128
- r += 1
129
- _widths(ws, [28, 50])
130
-
131
-
132
- def _income_sheet(wb, data):
133
- ws = wb.create_sheet("Income Statement")
134
- co, years = data.get("company",{}), data.get("years",[])
135
- units_label = data.get("units_label", data.get("units", "Crores"))
136
- currency = data.get("currency", co.get("currency", "INR"))
137
- _title(ws, f"Income Statement β€” {co.get('ticker','')}",
138
- f"All figures in {units_label}")
139
- _hdr(ws, 4, [f"({units_label})"] + years)
140
- inc = data.get("income_statement", {})
141
- r = _stmt_section(ws, 5, "Revenue & Profitability", [
142
- ("Revenue", "revenue"),
143
- ("Cost of Revenue", "cost_of_revenue"),
144
- ("Gross Profit", "gross_profit"),
145
- ("Operating Expenses", "operating_expenses"),
146
- ("Operating Income (EBIT)","operating_income"),
147
- ("D&A", "depreciation_amort"),
148
- ("EBITDA", "ebitda"),
149
- ("Interest Expense", "interest_expense"),
150
- ("Pretax Income", "pretax_income"),
151
- ("Tax Expense", "tax_expense"),
152
- ("Net Income", "net_income"),
153
- ], inc, years)
154
- r += 1
155
- _sec(ws, r, "Per-Share Data", 1+len(years)); r += 1
156
- _data_row(ws, r, "Diluted EPS", inc.get("diluted_eps",[None]*len(years))[:len(years)], FMT_DEC); r += 1
157
- _data_row(ws, r, "Diluted Shares (M)", inc.get("diluted_shares",[None]*len(years))[:len(years)], FMT_MONEY)
158
-
159
- # Add revenue bar chart
160
- if len(years) >= 2:
161
- chart = BarChart()
162
- chart.title = f"Revenue Trend ({units_label})"
163
- chart.style = 10
164
- chart.y_axis.title = units_label
165
- rev_row = 6
166
- data_ref = Reference(ws, min_col=2, max_col=1+len(years), min_row=rev_row, max_row=rev_row)
167
- cats_ref = Reference(ws, min_col=2, max_col=1+len(years), min_row=4, max_row=4)
168
- chart.add_data(data_ref)
169
- chart.set_categories(cats_ref)
170
- chart.shape = 4
171
- ws.add_chart(chart, f"A{r+3}")
172
-
173
- _widths(ws, [34]+[16]*len(years)); _freeze(ws, 4)
174
-
175
-
176
- def _balance_sheet(wb, data):
177
- ws = wb.create_sheet("Balance Sheet")
178
- co, years = data.get("company",{}), data.get("years",[])
179
- units_label = data.get("units_label", data.get("units", "Crores"))
180
- _title(ws, f"Balance Sheet β€” {co.get('ticker','')}",
181
- f"As of fiscal year-end. {units_label}")
182
- _hdr(ws, 4, [f"({units_label})"] + years)
183
- bs = data.get("balance_sheet", {})
184
- r = 5
185
- for sec_title, items in [
186
- ("Current Assets", [
187
- ("Cash & Equivalents","cash_and_equivalents"),("Short-term Investments","short_term_investments"),
188
- ("Receivables","receivables"),("Inventory","inventory"),("Other Current Assets","other_current_assets"),
189
- ("Total Current Assets","total_current_assets"),
190
- ]),
191
- ("Non-Current Assets", [
192
- ("PP&E (Net)","ppe_net"),("Goodwill","goodwill"),("Intangibles","intangibles"),
193
- ("Other LT Assets","other_long_term_assets"),("Total Assets","total_assets"),
194
- ]),
195
- ("Current Liabilities", [
196
- ("Accounts Payable","accounts_payable"),("Short-term Debt","short_term_debt"),
197
- ("Other Current Liabilities","other_current_liabilities"),
198
- ("Total Current Liabilities","total_current_liabilities"),
199
- ]),
200
- ("Non-Current Liabilities & Equity", [
201
- ("Long-term Debt","long_term_debt"),("Other LT Liabilities","other_long_term_liabilities"),
202
- ("Total Liabilities","total_liabilities"),("Total Equity","total_equity"),
203
- ("Total Liabilities + Equity","total_liab_and_equity"),
204
- ]),
205
- ]:
206
- r = _stmt_section(ws, r, sec_title, items, bs, years)
207
- _widths(ws, [36]+[16]*len(years)); _freeze(ws, 4)
208
-
209
-
210
- def _cashflow_sheet(wb, data):
211
- ws = wb.create_sheet("Cash Flow")
212
- co, years = data.get("company",{}), data.get("years",[])
213
- units_label = data.get("units_label", data.get("units", "Crores"))
214
- _title(ws, f"Cash Flow Statement β€” {co.get('ticker','')}",
215
- f"All figures in {units_label}")
216
- _hdr(ws, 4, [f"({units_label})"] + years)
217
- cf = data.get("cash_flow", {})
218
- r = 5
219
- for sec_title, items in [
220
- ("Operating Activities", [
221
- ("Net Income","net_income"),("D&A","depreciation_amort"),("Stock-Based Comp","stock_based_comp"),
222
- ("Working Capital Changes","working_capital_changes"),("Cash from Operations","cash_from_operations"),
223
- ]),
224
- ("Investing Activities", [
225
- ("Capital Expenditures","capex"),("Acquisitions","acquisitions"),("Cash from Investing","cash_from_investing"),
226
- ]),
227
- ("Financing Activities", [
228
- ("Debt Issuance/(Repayment)","debt_issuance_repayment"),("Buybacks","stock_buybacks"),
229
- ("Dividends Paid","dividends_paid"),("Cash from Financing","cash_from_financing"),
230
- ]),
231
- ("Summary", [
232
- ("Net Change in Cash","net_change_in_cash"),("Free Cash Flow","free_cash_flow"),
233
- ]),
234
- ]:
235
- r = _stmt_section(ws, r, sec_title, items, cf, years)
236
- _widths(ws, [36]+[16]*len(years)); _freeze(ws, 4)
237
-
238
-
239
- RATIO_SECTIONS = [
240
- ("Profitability", [
241
- ("gross_margin","Gross Margin",FMT_PCT),("operating_margin","Operating Margin",FMT_PCT),
242
- ("ebitda_margin","EBITDA Margin",FMT_PCT),("net_margin","Net Margin",FMT_PCT),
243
- ("roe","ROE",FMT_PCT),("roa","ROA",FMT_PCT),("roic","ROIC",FMT_PCT),
244
- ]),
245
- ("Liquidity", [
246
- ("current_ratio","Current Ratio",FMT_RATIO),("quick_ratio","Quick Ratio",FMT_RATIO),
247
- ("cash_ratio","Cash Ratio",FMT_RATIO),
248
- ]),
249
- ("Solvency / Leverage", [
250
- ("debt_to_equity","Debt-to-Equity",FMT_RATIO),("debt_to_assets","Debt-to-Assets",FMT_RATIO),
251
- ("net_debt_to_ebitda","Net Debt/EBITDA",FMT_RATIO),("interest_coverage","Interest Coverage",FMT_RATIO),
252
- ]),
253
- ("Efficiency", [
254
- ("asset_turnover","Asset Turnover",FMT_RATIO),("inventory_turnover","Inventory Turnover",FMT_RATIO),
255
- ("days_inventory","Days Inventory (DIO)",FMT_DAYS),("days_sales_outstanding","DSO",FMT_DAYS),
256
- ("days_payables","DPO",FMT_DAYS),("cash_conversion_cycle","Cash Conversion Cycle",FMT_DAYS),
257
- ]),
258
- ("Cash Flow Quality", [
259
- ("ocf_to_net_income","OCF/Net Income",FMT_RATIO),("fcf_margin","FCF Margin",FMT_PCT),
260
- ("fcf_conversion","FCF Conversion",FMT_RATIO),("capex_intensity","Capex/Revenue",FMT_PCT),
261
- ]),
262
- ]
263
-
264
-
265
- def _ratios_sheet(wb, data):
266
- ws = wb.create_sheet("Ratios")
267
- years = data.get("years", [])
268
- _title(ws, f"Financial Ratios β€” {data.get('company',{}).get('ticker','')}", "5-year trend")
269
- _hdr(ws, 4, ["Ratio"] + years)
270
- ratios = data.get("ratios", {})
271
- r = 5
272
- for sec_name, items in RATIO_SECTIONS:
273
- _sec(ws, r, sec_name, 1+len(years)); r += 1
274
- for key, label, fmt in items:
275
- vals = ratios.get(key, [None]*len(years))
276
- vals = (vals + [None]*len(years))[:len(years)]
277
- _data_row(ws, r, label, vals, fmt); r += 1
278
- _widths(ws, [40]+[14]*len(years)); _freeze(ws, 4)
279
-
280
-
281
- def _dupont_sheet(wb, data):
282
- ws = wb.create_sheet("DuPont Analysis")
283
- years = data.get("years", [])
284
- _title(ws, "DuPont Decomposition", "ROE = Net Margin Γ— Asset Turnover Γ— Equity Multiplier")
285
- _hdr(ws, 4, ["Component"] + years)
286
- dp = data.get("dupont", {})
287
- r = 5
288
- for label, key, fmt in [
289
- ("Net Margin", "net_margin", FMT_PCT),
290
- ("Γ— Asset Turnover", "asset_turnover", FMT_RATIO),
291
- ("Γ— Equity Multiplier","equity_multiplier",FMT_RATIO),
292
- ("= ROE (DuPont)", "roe_computed", FMT_PCT),
293
- ("ROE (Reported)", "roe_reported", FMT_PCT),
294
- ]:
295
- vals = dp.get(key, [None]*len(years))
296
- vals = (vals + [None]*len(years))[:len(years)]
297
- _data_row(ws, r, label, vals, fmt)
298
- r += 1
299
- _widths(ws, [28]+[14]*len(years))
300
-
301
-
302
- def _growth_sheet(wb, data):
303
- ws = wb.create_sheet("Growth Metrics")
304
- years = data.get("years", [])
305
- yoy_hdrs = [f"{years[i]} vs {years[i-1]}" for i in range(1, len(years))] if len(years) > 1 else []
306
- _title(ws, "Growth Metrics", "YoY growth and CAGRs")
307
- _hdr(ws, 4, ["Metric"] + yoy_hdrs + ["CAGR"])
308
- g = data.get("growth_metrics", {})
309
- r = 5
310
- for label, yoy_key, cagr_key in [
311
- ("Revenue", "revenue_yoy", "revenue_cagr_5y"),
312
- ("Operating Income","operating_income_yoy","operating_income_cagr_5y"),
313
- ("Net Income", "net_income_yoy", "net_income_cagr_5y"),
314
- ("Diluted EPS", "eps_yoy", "eps_cagr_5y"),
315
- ("Free Cash Flow", "fcf_yoy", "fcf_cagr_5y"),
316
- ]:
317
- yoy = g.get(yoy_key, [])[1:] # skip first None
318
- cagr = g.get(cagr_key)
319
- vals = (yoy + [None]*len(yoy_hdrs))[:len(yoy_hdrs)] + [cagr]
320
- _data_row(ws, r, label, vals, FMT_PCT); r += 1
321
- _widths(ws, [28]+[16]*(len(yoy_hdrs)+1))
322
-
323
-
324
- def _peers_sheet(wb, data):
325
- ws = wb.create_sheet("Peer Comparison")
326
- co = data.get("company", {})
327
- _title(ws, f"Peer Comparison β€” {co.get('ticker','')}", "Latest fiscal year")
328
- cols = ["Company","Ticker","Revenue","Gross Margin","Op Margin","Net Margin",
329
- "ROE","ROIC","D/E","Current Ratio","FCF Margin","P/E","EV/EBITDA","Rationale"]
330
- _hdr(ws, 4, cols)
331
- r = 5
332
- years = data.get("years", [])
333
- rat = data.get("ratios", {}); inc = data.get("income_statement", {})
334
- val = data.get("valuation_snapshot", {})
335
- # Target row
336
- target = [
337
- co.get("name",""), co.get("ticker",""),
338
- _last(inc.get("revenue",[])), _last(rat.get("gross_margin",[])),
339
- _last(rat.get("operating_margin",[])), _last(rat.get("net_margin",[])),
340
- _last(rat.get("roe",[])), _last(rat.get("roic",[])),
341
- _last(rat.get("debt_to_equity",[])), _last(rat.get("current_ratio",[])),
342
- _last(rat.get("fcf_margin",[])), val.get("pe_ttm"), val.get("ev_ebitda"),
343
- "Target company",
344
- ]
345
- fmts = ["","","money","pct","pct","pct","pct","pct","ratio","ratio","pct","ratio","ratio",""]
346
- _write_peer_row(ws, r, target, fmts, is_target=True); r += 1
347
- for p in data.get("peers", []):
348
- peer_row = [
349
- p.get("name",""), p.get("ticker",""), p.get("revenue"),
350
- p.get("gross_margin"), p.get("operating_margin"), p.get("net_margin"),
351
- p.get("roe"), p.get("roic"), p.get("debt_to_equity"), p.get("current_ratio"),
352
- p.get("fcf_margin"), p.get("pe_ttm"), p.get("ev_ebitda"), p.get("rationale",""),
353
- ]
354
- _write_peer_row(ws, r, peer_row, fmts); r += 1
355
- _widths(ws, [24,10,16,12,12,12,10,10,10,12,12,10,12,50])
356
-
357
-
358
- def _write_peer_row(ws, row, values, fmts, is_target=False):
359
- fmt_map = {"money":FMT_MONEY,"pct":FMT_PCT,"ratio":FMT_RATIO}
360
- fill = PatternFill("solid", fgColor="FFF2CC") if is_target else None
361
- for i, v in enumerate(values):
362
- c = ws.cell(row=row, column=i+1, value=v if v is not None else "β€”")
363
- c.border = THIN
364
- c.font = Font(name="Calibri", size=11, bold=is_target)
365
- if fill: c.fill = fill
366
- fmt = fmts[i] if i < len(fmts) else ""
367
- if fmt in fmt_map and isinstance(v, (int, float)):
368
- c.number_format = fmt_map[fmt]
369
- c.alignment = RIGHT
370
- else:
371
- c.alignment = LEFT
372
-
373
-
374
- def _swot_sheet(wb, data):
375
- ws = wb.create_sheet("SWOT Analysis")
376
- _title(ws, "SWOT Analysis", "Strengths, Weaknesses, Opportunities, Threats")
377
- swot = data.get("swot", {})
378
- r = 4
379
- colors = {"Strengths":"C6EFCE","Weaknesses":"FFC7CE","Opportunities":"DDEBF7","Threats":"FFEB9C"}
380
- for category, key in [("Strengths","strengths"),("Weaknesses","weaknesses"),
381
- ("Opportunities","opportunities"),("Threats","threats")]:
382
- fill = PatternFill("solid", fgColor=colors[category])
383
- hdr_cell = ws.cell(row=r, column=1, value=category)
384
- hdr_cell.font = Font(name="Calibri", size=12, bold=True)
385
- hdr_cell.fill = fill
386
- r += 1
387
- for item in swot.get(key, []):
388
- c = ws.cell(row=r, column=1, value=f"β€’ {item}")
389
- c.font = Font(name="Calibri", size=11)
390
- c.alignment = Alignment(horizontal="left", vertical="top", wrap_text=True)
391
- ws.row_dimensions[r].height = 32
392
- r += 1
393
- r += 1
394
- _widths(ws, [90])
395
-
396
-
397
- def _dcf_sheet(wb, data):
398
- ws = wb.create_sheet("DCF Valuation")
399
- _title(ws, "Discounted Cash Flow (DCF) Model", "Interactive Valuation Model")
400
-
401
- co = data.get("company", {})
402
- cf = data.get("cash_flow", {})
403
- inc = data.get("income_statement", {})
404
- bal = data.get("balance_sheet", {})
405
-
406
- # Get latest data
407
- fcf_history = [v for v in cf.get("free_cash_flow", []) if v is not None]
408
- latest_fcf = fcf_history[-1] if fcf_history else 0
409
-
410
- shares_history = [v for v in inc.get("diluted_shares", []) if v is not None]
411
- latest_shares = shares_history[-1] if shares_history else 1
412
-
413
- cash_history = [v for v in bal.get("cash_and_equivalents", []) if v is not None]
414
- cash = cash_history[-1] if cash_history else 0
415
-
416
- debt_history = [v for v in bal.get("total_liabilities", []) if v is not None]
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
-
427
- ws.cell(row=r, column=1, value="Starting FCF (Base)").font = LBL_FONT
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
435
-
436
- ws.cell(row=r, column=1, value="Cash & Equivalents").font = LBL_FONT
437
- ws.cell(row=r, column=2, value=cash).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
438
-
439
- ws.cell(row=r, column=1, value="Total Debt").font = LBL_FONT
440
- ws.cell(row=r, column=2, value=debt).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
441
-
442
- ws.cell(row=r, column=1, value="Diluted Shares").font = LBL_FONT
443
- ws.cell(row=r, column=2, value=latest_shares).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
444
-
445
- r += 1
446
- _sec(ws, r, "Projected Cash Flows (5 Years)", 6); r += 1
447
- _hdr(ws, r, ["Year 1", "Year 2", "Year 3", "Year 4", "Year 5"], start=2); r += 1
448
-
449
- # Growth rates
450
- ws.cell(row=r, column=1, value="Projected Growth Rate").font = LBL_FONT
451
- for c in range(2, 7):
452
- ws.cell(row=r, column=c, value=0.05).font = DAT_FONT; ws.cell(row=r, column=c).number_format = FMT_PCT; ws.cell(row=r, column=c).fill = PatternFill("solid", fgColor="FFFFE0")
453
- r += 1
454
-
455
- # FCF Projection Formulas
456
- ws.cell(row=r, column=1, value="Projected FCF").font = LBL_FONT
457
- ws.cell(row=r, column=2, value="=B5*(1+B14)") # Year 1 = Base * (1+g1)
458
- ws.cell(row=r, column=3, value="=B15*(1+C14)")
459
- ws.cell(row=r, column=4, value="=C15*(1+D14)")
460
- ws.cell(row=r, column=5, value="=D15*(1+E14)")
461
- ws.cell(row=r, column=6, value="=E15*(1+F14)")
462
- for c in range(2, 7):
463
- ws.cell(row=r, column=c).font = DAT_FONT; ws.cell(row=r, column=c).number_format = FMT_MONEY
464
- r += 1
465
-
466
- # Discount Factor Formulas
467
- ws.cell(row=r, column=1, value="Discount Factor").font = LBL_FONT
468
- ws.cell(row=r, column=2, value="=1/((1+$B$6)^1)")
469
- ws.cell(row=r, column=3, value="=1/((1+$B$6)^2)")
470
- ws.cell(row=r, column=4, value="=1/((1+$B$6)^3)")
471
- ws.cell(row=r, column=5, value="=1/((1+$B$6)^4)")
472
- ws.cell(row=r, column=6, value="=1/((1+$B$6)^5)")
473
- for c in range(2, 7):
474
- ws.cell(row=r, column=c).font = DAT_FONT; ws.cell(row=r, column=c).number_format = FMT_RATIO
475
- r += 1
476
-
477
- # PV of FCF Formulas
478
- ws.cell(row=r, column=1, value="PV of FCF").font = LBL_FONT
479
- for c, col_l in enumerate(["B", "C", "D", "E", "F"], start=2):
480
- ws.cell(row=r, column=c, value=f"={col_l}15*{col_l}16").font = DAT_FONT; ws.cell(row=r, column=c).number_format = FMT_MONEY
481
- r += 2
482
-
483
- # Valuation Output
484
- _sec(ws, r, "Valuation Output", 2); r += 1
485
-
486
- ws.cell(row=r, column=1, value="Sum of PV of FCF").font = LBL_FONT
487
- ws.cell(row=r, column=2, value="=SUM(B17:F17)").font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; r += 1
488
-
489
- ws.cell(row=r, column=1, value="Terminal Value").font = LBL_FONT
490
- ws.cell(row=r, column=2, value="=(F15*(1+B7))/(B6-B7)").font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; r += 1
491
-
492
- ws.cell(row=r, column=1, value="PV of Terminal Value").font = LBL_FONT
493
- ws.cell(row=r, column=2, value="=B21*F16").font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; r += 1
494
-
495
- ws.cell(row=r, column=1, value="Implied Enterprise Value").font = LBL_FONT
496
- ws.cell(row=r, column=2, value="=B20+B22").font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; r += 1
497
-
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
-
509
-
510
- def _narrative_sheet(wb, data):
511
- ws = wb.create_sheet("Industry & AI Insights")
512
- _title(ws, "AI Narrative Analysis", "Generated by AI Models")
513
- r = 4
514
-
515
- exec_summary = data.get("executive_summary", "")
516
- if exec_summary:
517
- _sec(ws, r, "Executive Summary", 2); r += 1
518
- c = ws.cell(row=r, column=1, value=exec_summary)
519
- c.font = LBL_FONT; c.alignment = Alignment(wrap_text=True, vertical="top")
520
- ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=4)
521
- ws.row_dimensions[r].height = 100
522
- r += 2
523
-
524
- industry_trends = data.get("industry_trends", "")
525
- if industry_trends:
526
- _sec(ws, r, "Industry Trends", 2); r += 1
527
- c = ws.cell(row=r, column=1, value=industry_trends)
528
- c.font = LBL_FONT; c.alignment = Alignment(wrap_text=True, vertical="top")
529
- ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=4)
530
- ws.row_dimensions[r].height = 120
531
- r += 2
532
-
533
- _widths(ws, [100])
534
-
535
-
536
- def _sources_sheet(wb, data):
537
- ws = wb.create_sheet("Sources")
538
- _title(ws, "Sources", "All data sources and retrieval dates")
539
- _hdr(ws, 4, ["#","URL","Description","Retrieved"])
540
- r = 5
541
- for i, src in enumerate(data.get("sources",[]), start=1):
542
- ws.cell(row=r, column=1, value=i).alignment = CENTER
543
- url = src.get("url","")
544
- uc = ws.cell(row=r, column=2, value=url)
545
- uc.font = Font(name="Calibri", size=10, color="0563C1", underline="single")
546
- uc.alignment = LEFT
547
- if url:
548
- try: uc.hyperlink = url
549
- except: pass
550
- ws.cell(row=r, column=3, value=src.get("description","")).alignment = LEFT
551
- ws.cell(row=r, column=4, value=src.get("retrieved","")).alignment = CENTER
552
- for col in range(1,5): ws.cell(row=r,column=col).border = THIN
553
- ws.row_dimensions[r].height = 28; r += 1
554
- _widths(ws, [6,70,50,16])
555
-
556
-
557
- # ── Orchestrator ──────────────────────────────────────────────────────────────
558
-
559
- def build_workbook(data: dict, output_path: str) -> None:
560
- wb = Workbook()
561
- wb.remove(wb.active)
562
- _summary_sheet(wb, data)
563
- _income_sheet(wb, data)
564
- _balance_sheet(wb, data)
565
- _cashflow_sheet(wb, data)
566
- _ratios_sheet(wb, data)
567
- _dupont_sheet(wb, data)
568
- _dcf_sheet(wb, data)
569
- _growth_sheet(wb, data)
570
- _peers_sheet(wb, data)
571
- _swot_sheet(wb, data)
572
- _narrative_sheet(wb, data)
573
- _sources_sheet(wb, data)
574
- wb.save(output_path)
575
-
576
-
577
- if __name__ == "__main__":
578
- import argparse
579
- p = argparse.ArgumentParser()
580
- p.add_argument("--input", "-i", required=True)
581
- p.add_argument("--output", "-o", required=True)
582
- args = p.parse_args()
583
- with open(args.input, encoding="utf-8") as f:
584
- data = json.load(f)
585
- Path(args.output).parent.mkdir(parents=True, exist_ok=True)
586
- build_workbook(data, args.output)
587
- print(f"Wrote: {args.output}")
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ builders/build_excel.py β€” Enhanced Excel workbook builder.
4
+ Reads the skill's original builder and adds DuPont, SWOT, and Indian unit support.
5
+ """
6
+ import json, sys, datetime
7
+ from pathlib import Path
8
+
9
+ try:
10
+ from openpyxl import Workbook
11
+ from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
12
+ from openpyxl.utils import get_column_letter
13
+ from openpyxl.chart import BarChart, LineChart, Reference
14
+ except ImportError:
15
+ print("ERROR: pip install openpyxl", file=sys.stderr); sys.exit(1)
16
+
17
+ # ── Style constants ───────────────────────────────────────────────────────────
18
+ HDR_FILL = PatternFill("solid", fgColor="1F4E78")
19
+ HDR_FONT = Font(name="Calibri", size=11, bold=True, color="FFFFFF")
20
+ SEC_FILL = PatternFill("solid", fgColor="D9E1F2")
21
+ SEC_FONT = Font(name="Calibri", size=11, bold=True, color="1F4E78")
22
+ LBL_FONT = Font(name="Calibri", size=11)
23
+ TTL_FONT = Font(name="Calibri", size=16, bold=True, color="1F4E78")
24
+ DAT_FONT = Font(name="Calibri", size=11)
25
+ GRN_FILL = PatternFill("solid", fgColor="C6EFCE")
26
+ RED_FILL = PatternFill("solid", fgColor="FFC7CE")
27
+ YLW_FILL = PatternFill("solid", fgColor="FFEB9C")
28
+
29
+ THIN = Border(
30
+ left=Side(style="thin", color="BFBFBF"), right=Side(style="thin", color="BFBFBF"),
31
+ top=Side(style="thin", color="BFBFBF"), bottom=Side(style="thin", color="BFBFBF"),
32
+ )
33
+ CENTER = Alignment(horizontal="center", vertical="center", wrap_text=True)
34
+ LEFT = Alignment(horizontal="left", vertical="center", wrap_text=True)
35
+ RIGHT = Alignment(horizontal="right", vertical="center")
36
+
37
+ FMT_MONEY = '#,##0;(#,##0);"β€”"'
38
+ FMT_DEC = '#,##0.00;(#,##0.00);"β€”"'
39
+ FMT_PCT = '0.0%;(0.0%);"β€”"'
40
+ FMT_RATIO = '0.00;(0.00);"β€”"'
41
+ FMT_DAYS = '0.0;"β€”"'
42
+
43
+ # ── Helpers ───────────────────────────────────────────────────────────────────
44
+
45
+ def _safe(arr, i, default=None):
46
+ try: return arr[i] if arr and arr[i] is not None else default
47
+ except: return default
48
+
49
+ def _hdr(ws, row, cols, start=1):
50
+ for i, h in enumerate(cols):
51
+ c = ws.cell(row=row, column=start+i, value=h)
52
+ c.font, c.fill, c.alignment, c.border = HDR_FONT, HDR_FILL, CENTER, THIN
53
+
54
+ def _sec(ws, row, label, ncols, start=1):
55
+ ws.cell(row=row, column=start, value=label).font = SEC_FONT
56
+ ws.cell(row=row, column=start).fill = SEC_FILL
57
+ ws.cell(row=row, column=start).alignment = LEFT
58
+ for col in range(start+1, start+ncols):
59
+ ws.cell(row=row, column=col).fill = SEC_FILL
60
+
61
+ def _data_row(ws, row, label, values, fmt=FMT_MONEY, start=1):
62
+ c = ws.cell(row=row, column=start, value=label)
63
+ c.font, c.alignment, c.border = LBL_FONT, LEFT, THIN
64
+ for i, v in enumerate(values):
65
+ cell = ws.cell(row=row, column=start+1+i, value=v if v is not None else "β€”")
66
+ cell.font, cell.alignment, cell.border = DAT_FONT, RIGHT, THIN
67
+ if v is not None and isinstance(v, (int, float)):
68
+ cell.number_format = fmt
69
+
70
+ def _title(ws, title, subtitle=None):
71
+ ws.cell(row=1, column=1, value=title).font = TTL_FONT
72
+ if subtitle:
73
+ ws.cell(row=2, column=1, value=subtitle).font = Font(name="Calibri", size=11, italic=True, color="595959")
74
+
75
+ def _widths(ws, widths):
76
+ for i, w in enumerate(widths):
77
+ ws.column_dimensions[get_column_letter(i+1)].width = w
78
+
79
+ def _freeze(ws, hdr_row):
80
+ ws.freeze_panes = ws.cell(row=hdr_row+1, column=2)
81
+
82
+ def _last(arr):
83
+ if not arr: return None
84
+ for v in reversed(arr):
85
+ if v is not None: return v
86
+ return None
87
+
88
+ # ── Statement section helper ──────────────────────────────────────────────────
89
+
90
+ def _stmt_section(ws, row, title, items, data_dict, years, fmt=FMT_MONEY):
91
+ _sec(ws, row, title, 1+len(years))
92
+ row += 1
93
+ for label, key in items:
94
+ vals = data_dict.get(key, [None]*len(years))
95
+ vals = (vals + [None]*len(years))[:len(years)]
96
+ _data_row(ws, row, label, vals, fmt)
97
+ row += 1
98
+ return row
99
+
100
+ # ── Sheet builders ────────────────────────────────────────────────────────────
101
+
102
+ def _summary_sheet(wb, data):
103
+ ws = wb.create_sheet("Summary")
104
+ co = data.get("company", {})
105
+ units_label = data.get("units_label", data.get("units", "Crores"))
106
+ currency = data.get("currency", co.get("currency", "INR"))
107
+ _title(ws, f"{co.get('name','')} ({co.get('ticker','')}) β€” Financial Analysis",
108
+ f"Report date: {co.get('report_date', datetime.date.today().isoformat())}")
109
+ r = 4
110
+ for label, val in [
111
+ ("Company", co.get("name","")),
112
+ ("Ticker", co.get("ticker","")),
113
+ ("Exchange", co.get("exchange","")),
114
+ ("Industry", co.get("industry","")),
115
+ ("Sector", co.get("sector","")),
116
+ ("Currency", currency),
117
+ ("Reporting Units", units_label),
118
+ ("Fiscal Year End", co.get("fiscal_year_end","")),
119
+ ("Analysis Period", f"{len(data.get('years',[]))} years ({', '.join(data.get('years',[]))})"),
120
+ ("Share Price", co.get("share_price")),
121
+ ("Market Cap", co.get("market_cap")),
122
+ ("Data Source", data.get("source", "Yahoo Finance (yahooquery)")),
123
+ ("Models Used", ", ".join(data.get("models_used", []) or ["None"])),
124
+ ("Warnings", " | ".join(data.get("warnings", []))),
125
+ ]:
126
+ ws.cell(row=r, column=1, value=label).font = LBL_FONT
127
+ ws.cell(row=r, column=2, value=val).font = DAT_FONT
128
+ r += 1
129
+ _widths(ws, [28, 50])
130
+
131
+
132
+ def _income_sheet(wb, data):
133
+ ws = wb.create_sheet("Income Statement")
134
+ co, years = data.get("company",{}), data.get("years",[])
135
+ units_label = data.get("units_label", data.get("units", "Crores"))
136
+ currency = data.get("currency", co.get("currency", "INR"))
137
+ _title(ws, f"Income Statement β€” {co.get('ticker','')}",
138
+ f"All figures in {units_label}")
139
+ _hdr(ws, 4, [f"({units_label})"] + years)
140
+ inc = data.get("income_statement", {})
141
+ r = _stmt_section(ws, 5, "Revenue & Profitability", [
142
+ ("Revenue", "revenue"),
143
+ ("Cost of Revenue", "cost_of_revenue"),
144
+ ("Gross Profit", "gross_profit"),
145
+ ("Operating Expenses", "operating_expenses"),
146
+ ("Operating Income (EBIT)","operating_income"),
147
+ ("D&A", "depreciation_amort"),
148
+ ("EBITDA", "ebitda"),
149
+ ("Interest Expense", "interest_expense"),
150
+ ("Pretax Income", "pretax_income"),
151
+ ("Tax Expense", "tax_expense"),
152
+ ("Net Income", "net_income"),
153
+ ], inc, years)
154
+ r += 1
155
+ _sec(ws, r, "Per-Share Data", 1+len(years)); r += 1
156
+ _data_row(ws, r, "Diluted EPS", inc.get("diluted_eps",[None]*len(years))[:len(years)], FMT_DEC); r += 1
157
+ _data_row(ws, r, "Diluted Shares (M)", inc.get("diluted_shares",[None]*len(years))[:len(years)], FMT_MONEY)
158
+
159
+ # Add revenue bar chart
160
+ if len(years) >= 2:
161
+ chart = BarChart()
162
+ chart.title = f"Revenue Trend ({units_label})"
163
+ chart.style = 10
164
+ chart.y_axis.title = units_label
165
+ rev_row = 6
166
+ data_ref = Reference(ws, min_col=2, max_col=1+len(years), min_row=rev_row, max_row=rev_row)
167
+ cats_ref = Reference(ws, min_col=2, max_col=1+len(years), min_row=4, max_row=4)
168
+ chart.add_data(data_ref)
169
+ chart.set_categories(cats_ref)
170
+ chart.shape = 4
171
+ ws.add_chart(chart, f"A{r+3}")
172
+
173
+ _widths(ws, [34]+[16]*len(years)); _freeze(ws, 4)
174
+
175
+
176
+ def _balance_sheet(wb, data):
177
+ ws = wb.create_sheet("Balance Sheet")
178
+ co, years = data.get("company",{}), data.get("years",[])
179
+ units_label = data.get("units_label", data.get("units", "Crores"))
180
+ _title(ws, f"Balance Sheet β€” {co.get('ticker','')}",
181
+ f"As of fiscal year-end. {units_label}")
182
+ _hdr(ws, 4, [f"({units_label})"] + years)
183
+ bs = data.get("balance_sheet", {})
184
+ r = 5
185
+ for sec_title, items in [
186
+ ("Current Assets", [
187
+ ("Cash & Equivalents","cash_and_equivalents"),("Short-term Investments","short_term_investments"),
188
+ ("Receivables","receivables"),("Inventory","inventory"),("Other Current Assets","other_current_assets"),
189
+ ("Total Current Assets","total_current_assets"),
190
+ ]),
191
+ ("Non-Current Assets", [
192
+ ("PP&E (Net)","ppe_net"),("Goodwill","goodwill"),("Intangibles","intangibles"),
193
+ ("Other LT Assets","other_long_term_assets"),("Total Assets","total_assets"),
194
+ ]),
195
+ ("Current Liabilities", [
196
+ ("Accounts Payable","accounts_payable"),("Short-term Debt","short_term_debt"),
197
+ ("Other Current Liabilities","other_current_liabilities"),
198
+ ("Total Current Liabilities","total_current_liabilities"),
199
+ ]),
200
+ ("Non-Current Liabilities & Equity", [
201
+ ("Long-term Debt","long_term_debt"),("Other LT Liabilities","other_long_term_liabilities"),
202
+ ("Total Liabilities","total_liabilities"),("Total Equity","total_equity"),
203
+ ("Total Liabilities + Equity","total_liab_and_equity"),
204
+ ]),
205
+ ]:
206
+ r = _stmt_section(ws, r, sec_title, items, bs, years)
207
+ _widths(ws, [36]+[16]*len(years)); _freeze(ws, 4)
208
+
209
+
210
+ def _cashflow_sheet(wb, data):
211
+ ws = wb.create_sheet("Cash Flow")
212
+ co, years = data.get("company",{}), data.get("years",[])
213
+ units_label = data.get("units_label", data.get("units", "Crores"))
214
+ _title(ws, f"Cash Flow Statement β€” {co.get('ticker','')}",
215
+ f"All figures in {units_label}")
216
+ _hdr(ws, 4, [f"({units_label})"] + years)
217
+ cf = data.get("cash_flow", {})
218
+ r = 5
219
+ for sec_title, items in [
220
+ ("Operating Activities", [
221
+ ("Net Income","net_income"),("D&A","depreciation_amort"),("Stock-Based Comp","stock_based_comp"),
222
+ ("Working Capital Changes","working_capital_changes"),("Cash from Operations","cash_from_operations"),
223
+ ]),
224
+ ("Investing Activities", [
225
+ ("Capital Expenditures","capex"),("Acquisitions","acquisitions"),("Cash from Investing","cash_from_investing"),
226
+ ]),
227
+ ("Financing Activities", [
228
+ ("Debt Issuance/(Repayment)","debt_issuance_repayment"),("Buybacks","stock_buybacks"),
229
+ ("Dividends Paid","dividends_paid"),("Cash from Financing","cash_from_financing"),
230
+ ]),
231
+ ("Summary", [
232
+ ("Net Change in Cash","net_change_in_cash"),("Free Cash Flow","free_cash_flow"),
233
+ ]),
234
+ ]:
235
+ r = _stmt_section(ws, r, sec_title, items, cf, years)
236
+ _widths(ws, [36]+[16]*len(years)); _freeze(ws, 4)
237
+
238
+
239
+ RATIO_SECTIONS = [
240
+ ("Profitability", [
241
+ ("gross_margin","Gross Margin",FMT_PCT),("operating_margin","Operating Margin",FMT_PCT),
242
+ ("ebitda_margin","EBITDA Margin",FMT_PCT),("net_margin","Net Margin",FMT_PCT),
243
+ ("roe","ROE",FMT_PCT),("roa","ROA",FMT_PCT),("roic","ROIC",FMT_PCT),
244
+ ]),
245
+ ("Liquidity", [
246
+ ("current_ratio","Current Ratio",FMT_RATIO),("quick_ratio","Quick Ratio",FMT_RATIO),
247
+ ("cash_ratio","Cash Ratio",FMT_RATIO),
248
+ ]),
249
+ ("Solvency / Leverage", [
250
+ ("debt_to_equity","Debt-to-Equity",FMT_RATIO),("debt_to_assets","Debt-to-Assets",FMT_RATIO),
251
+ ("net_debt_to_ebitda","Net Debt/EBITDA",FMT_RATIO),("interest_coverage","Interest Coverage",FMT_RATIO),
252
+ ]),
253
+ ("Efficiency", [
254
+ ("asset_turnover","Asset Turnover",FMT_RATIO),("inventory_turnover","Inventory Turnover",FMT_RATIO),
255
+ ("days_inventory","Days Inventory (DIO)",FMT_DAYS),("days_sales_outstanding","DSO",FMT_DAYS),
256
+ ("days_payables","DPO",FMT_DAYS),("cash_conversion_cycle","Cash Conversion Cycle",FMT_DAYS),
257
+ ]),
258
+ ("Cash Flow Quality", [
259
+ ("ocf_to_net_income","OCF/Net Income",FMT_RATIO),("fcf_margin","FCF Margin",FMT_PCT),
260
+ ("fcf_conversion","FCF Conversion",FMT_RATIO),("capex_intensity","Capex/Revenue",FMT_PCT),
261
+ ]),
262
+ ]
263
+
264
+
265
+ def _ratios_sheet(wb, data):
266
+ ws = wb.create_sheet("Ratios")
267
+ years = data.get("years", [])
268
+ _title(ws, f"Financial Ratios β€” {data.get('company',{}).get('ticker','')}", "5-year trend")
269
+ _hdr(ws, 4, ["Ratio"] + years)
270
+ ratios = data.get("ratios", {})
271
+ r = 5
272
+ for sec_name, items in RATIO_SECTIONS:
273
+ _sec(ws, r, sec_name, 1+len(years)); r += 1
274
+ for key, label, fmt in items:
275
+ vals = ratios.get(key, [None]*len(years))
276
+ vals = (vals + [None]*len(years))[:len(years)]
277
+ _data_row(ws, r, label, vals, fmt); r += 1
278
+ _widths(ws, [40]+[14]*len(years)); _freeze(ws, 4)
279
+
280
+
281
+ def _dupont_sheet(wb, data):
282
+ ws = wb.create_sheet("DuPont Analysis")
283
+ years = data.get("years", [])
284
+ _title(ws, "DuPont Decomposition", "ROE = Net Margin Γ— Asset Turnover Γ— Equity Multiplier")
285
+ _hdr(ws, 4, ["Component"] + years)
286
+ dp = data.get("dupont", {})
287
+ r = 5
288
+ for label, key, fmt in [
289
+ ("Net Margin", "net_margin", FMT_PCT),
290
+ ("Γ— Asset Turnover", "asset_turnover", FMT_RATIO),
291
+ ("Γ— Equity Multiplier","equity_multiplier",FMT_RATIO),
292
+ ("= ROE (DuPont)", "roe_computed", FMT_PCT),
293
+ ("ROE (Reported)", "roe_reported", FMT_PCT),
294
+ ]:
295
+ vals = dp.get(key, [None]*len(years))
296
+ vals = (vals + [None]*len(years))[:len(years)]
297
+ _data_row(ws, r, label, vals, fmt)
298
+ r += 1
299
+ _widths(ws, [28]+[14]*len(years))
300
+
301
+
302
+ def _growth_sheet(wb, data):
303
+ ws = wb.create_sheet("Growth Metrics")
304
+ years = data.get("years", [])
305
+ yoy_hdrs = [f"{years[i]} vs {years[i-1]}" for i in range(1, len(years))] if len(years) > 1 else []
306
+ _title(ws, "Growth Metrics", "YoY growth and CAGRs")
307
+ _hdr(ws, 4, ["Metric"] + yoy_hdrs + ["CAGR"])
308
+ g = data.get("growth_metrics", {})
309
+ r = 5
310
+ for label, yoy_key, cagr_key in [
311
+ ("Revenue", "revenue_yoy", "revenue_cagr_5y"),
312
+ ("Operating Income","operating_income_yoy","operating_income_cagr_5y"),
313
+ ("Net Income", "net_income_yoy", "net_income_cagr_5y"),
314
+ ("Diluted EPS", "eps_yoy", "eps_cagr_5y"),
315
+ ("Free Cash Flow", "fcf_yoy", "fcf_cagr_5y"),
316
+ ]:
317
+ yoy = g.get(yoy_key, [])[1:] # skip first None
318
+ cagr = g.get(cagr_key)
319
+ vals = (yoy + [None]*len(yoy_hdrs))[:len(yoy_hdrs)] + [cagr]
320
+ _data_row(ws, r, label, vals, FMT_PCT); r += 1
321
+ _widths(ws, [28]+[16]*(len(yoy_hdrs)+1))
322
+
323
+
324
+ def _peers_sheet(wb, data):
325
+ ws = wb.create_sheet("Peer Comparison")
326
+ co = data.get("company", {})
327
+ _title(ws, f"Peer Comparison β€” {co.get('ticker','')}", "Latest fiscal year")
328
+ cols = ["Company","Ticker","Revenue","Gross Margin","Op Margin","Net Margin",
329
+ "ROE","ROIC","D/E","Current Ratio","FCF Margin","P/E","EV/EBITDA","Rationale"]
330
+ _hdr(ws, 4, cols)
331
+ r = 5
332
+ years = data.get("years", [])
333
+ rat = data.get("ratios", {}); inc = data.get("income_statement", {})
334
+ val = data.get("valuation_snapshot", {})
335
+ # Target row
336
+ target = [
337
+ co.get("name",""), co.get("ticker",""),
338
+ _last(inc.get("revenue",[])), _last(rat.get("gross_margin",[])),
339
+ _last(rat.get("operating_margin",[])), _last(rat.get("net_margin",[])),
340
+ _last(rat.get("roe",[])), _last(rat.get("roic",[])),
341
+ _last(rat.get("debt_to_equity",[])), _last(rat.get("current_ratio",[])),
342
+ _last(rat.get("fcf_margin",[])), val.get("pe_ttm"), val.get("ev_ebitda"),
343
+ "Target company",
344
+ ]
345
+ fmts = ["","","money","pct","pct","pct","pct","pct","ratio","ratio","pct","ratio","ratio",""]
346
+ _write_peer_row(ws, r, target, fmts, is_target=True); r += 1
347
+ for p in data.get("peers", []):
348
+ peer_row = [
349
+ p.get("name",""), p.get("ticker",""), p.get("revenue"),
350
+ p.get("gross_margin"), p.get("operating_margin"), p.get("net_margin"),
351
+ p.get("roe"), p.get("roic"), p.get("debt_to_equity"), p.get("current_ratio"),
352
+ p.get("fcf_margin"), p.get("pe_ttm"), p.get("ev_ebitda"), p.get("rationale",""),
353
+ ]
354
+ _write_peer_row(ws, r, peer_row, fmts); r += 1
355
+ _widths(ws, [24,10,16,12,12,12,10,10,10,12,12,10,12,50])
356
+
357
+
358
+ def _write_peer_row(ws, row, values, fmts, is_target=False):
359
+ fmt_map = {"money":FMT_MONEY,"pct":FMT_PCT,"ratio":FMT_RATIO}
360
+ fill = PatternFill("solid", fgColor="FFF2CC") if is_target else None
361
+ for i, v in enumerate(values):
362
+ c = ws.cell(row=row, column=i+1, value=v if v is not None else "β€”")
363
+ c.border = THIN
364
+ c.font = Font(name="Calibri", size=11, bold=is_target)
365
+ if fill: c.fill = fill
366
+ fmt = fmts[i] if i < len(fmts) else ""
367
+ if fmt in fmt_map and isinstance(v, (int, float)):
368
+ c.number_format = fmt_map[fmt]
369
+ c.alignment = RIGHT
370
+ else:
371
+ c.alignment = LEFT
372
+
373
+
374
+ def _swot_sheet(wb, data):
375
+ ws = wb.create_sheet("SWOT Analysis")
376
+ _title(ws, "SWOT Analysis", "Strengths, Weaknesses, Opportunities, Threats")
377
+ swot = data.get("swot", {})
378
+ r = 4
379
+ colors = {"Strengths":"C6EFCE","Weaknesses":"FFC7CE","Opportunities":"DDEBF7","Threats":"FFEB9C"}
380
+ for category, key in [("Strengths","strengths"),("Weaknesses","weaknesses"),
381
+ ("Opportunities","opportunities"),("Threats","threats")]:
382
+ fill = PatternFill("solid", fgColor=colors[category])
383
+ hdr_cell = ws.cell(row=r, column=1, value=category)
384
+ hdr_cell.font = Font(name="Calibri", size=12, bold=True)
385
+ hdr_cell.fill = fill
386
+ r += 1
387
+ for item in swot.get(key, []):
388
+ c = ws.cell(row=r, column=1, value=f"β€’ {item}")
389
+ c.font = Font(name="Calibri", size=11)
390
+ c.alignment = Alignment(horizontal="left", vertical="top", wrap_text=True)
391
+ ws.row_dimensions[r].height = 32
392
+ r += 1
393
+ r += 1
394
+ _widths(ws, [90])
395
+
396
+
397
+ def _dcf_sheet(wb, data):
398
+ ws = wb.create_sheet("DCF Valuation")
399
+ _title(ws, "Discounted Cash Flow (DCF) Model", "Interactive Valuation Model")
400
+
401
+ co = data.get("company", {})
402
+ cf = data.get("cash_flow", {})
403
+ inc = data.get("income_statement", {})
404
+ bal = data.get("balance_sheet", {})
405
+
406
+ # Get latest data
407
+ fcf_history = [v for v in cf.get("free_cash_flow", []) if v is not None]
408
+ latest_fcf = fcf_history[-1] if fcf_history else 0
409
+
410
+ shares_history = [v for v in inc.get("diluted_shares", []) if v is not None]
411
+ latest_shares = shares_history[-1] if shares_history else 1
412
+
413
+ cash_history = [v for v in bal.get("cash_and_equivalents", []) if v is not None]
414
+ cash = cash_history[-1] if cash_history else 0
415
+
416
+ debt_history = [v for v in bal.get("total_liabilities", []) if v is not None]
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
+
427
+ ws.cell(row=r, column=1, value="Starting FCF (Base)").font = LBL_FONT
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
435
+
436
+ ws.cell(row=r, column=1, value="Cash & Equivalents").font = LBL_FONT
437
+ ws.cell(row=r, column=2, value=cash).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
438
+
439
+ ws.cell(row=r, column=1, value="Total Debt").font = LBL_FONT
440
+ ws.cell(row=r, column=2, value=debt).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
441
+
442
+ ws.cell(row=r, column=1, value="Diluted Shares").font = LBL_FONT
443
+ ws.cell(row=r, column=2, value=latest_shares).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
444
+
445
+ r += 1
446
+ _sec(ws, r, "Projected Cash Flows (5 Years)", 6); r += 1
447
+ _hdr(ws, r, ["Year 1", "Year 2", "Year 3", "Year 4", "Year 5"], start=2); r += 1
448
+
449
+ # Growth rates
450
+ ws.cell(row=r, column=1, value="Projected Growth Rate").font = LBL_FONT
451
+ for c in range(2, 7):
452
+ ws.cell(row=r, column=c, value=0.05).font = DAT_FONT; ws.cell(row=r, column=c).number_format = FMT_PCT; ws.cell(row=r, column=c).fill = PatternFill("solid", fgColor="FFFFE0")
453
+ r += 1
454
+
455
+ # FCF Projection Formulas
456
+ ws.cell(row=r, column=1, value="Projected FCF").font = LBL_FONT
457
+ ws.cell(row=r, column=2, value="=B5*(1+B14)") # Year 1 = Base * (1+g1)
458
+ ws.cell(row=r, column=3, value="=B15*(1+C14)")
459
+ ws.cell(row=r, column=4, value="=C15*(1+D14)")
460
+ ws.cell(row=r, column=5, value="=D15*(1+E14)")
461
+ ws.cell(row=r, column=6, value="=E15*(1+F14)")
462
+ for c in range(2, 7):
463
+ ws.cell(row=r, column=c).font = DAT_FONT; ws.cell(row=r, column=c).number_format = FMT_MONEY
464
+ r += 1
465
+
466
+ # Discount Factor Formulas
467
+ ws.cell(row=r, column=1, value="Discount Factor").font = LBL_FONT
468
+ ws.cell(row=r, column=2, value="=1/((1+$B$6)^1)")
469
+ ws.cell(row=r, column=3, value="=1/((1+$B$6)^2)")
470
+ ws.cell(row=r, column=4, value="=1/((1+$B$6)^3)")
471
+ ws.cell(row=r, column=5, value="=1/((1+$B$6)^4)")
472
+ ws.cell(row=r, column=6, value="=1/((1+$B$6)^5)")
473
+ for c in range(2, 7):
474
+ ws.cell(row=r, column=c).font = DAT_FONT; ws.cell(row=r, column=c).number_format = FMT_RATIO
475
+ r += 1
476
+
477
+ # PV of FCF Formulas
478
+ ws.cell(row=r, column=1, value="PV of FCF").font = LBL_FONT
479
+ for c, col_l in enumerate(["B", "C", "D", "E", "F"], start=2):
480
+ ws.cell(row=r, column=c, value=f"={col_l}15*{col_l}16").font = DAT_FONT; ws.cell(row=r, column=c).number_format = FMT_MONEY
481
+ r += 2
482
+
483
+ # Valuation Output
484
+ _sec(ws, r, "Valuation Output", 2); r += 1
485
+
486
+ ws.cell(row=r, column=1, value="Sum of PV of FCF").font = LBL_FONT
487
+ ws.cell(row=r, column=2, value="=SUM(B17:F17)").font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; r += 1
488
+
489
+ ws.cell(row=r, column=1, value="Terminal Value").font = LBL_FONT
490
+ ws.cell(row=r, column=2, value="=(F15*(1+B7))/(B6-B7)").font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; r += 1
491
+
492
+ ws.cell(row=r, column=1, value="PV of Terminal Value").font = LBL_FONT
493
+ ws.cell(row=r, column=2, value="=B21*F16").font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; r += 1
494
+
495
+ ws.cell(row=r, column=1, value="Implied Enterprise Value").font = LBL_FONT
496
+ ws.cell(row=r, column=2, value="=B20+B22").font = DAT_FONT; ws.cell(row=r, column=2).number_format = FMT_MONEY; r += 1
497
+
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
+
509
+
510
+ def _narrative_sheet(wb, data):
511
+ ws = wb.create_sheet("Industry & AI Insights")
512
+ _title(ws, "AI Narrative Analysis", "Generated by AI Models")
513
+ r = 4
514
+
515
+ exec_summary = data.get("executive_summary", "")
516
+ if exec_summary:
517
+ _sec(ws, r, "Executive Summary", 2); r += 1
518
+ c = ws.cell(row=r, column=1, value=exec_summary)
519
+ c.font = LBL_FONT; c.alignment = Alignment(wrap_text=True, vertical="top")
520
+ ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=4)
521
+ ws.row_dimensions[r].height = 100
522
+ r += 2
523
+
524
+ industry_trends = data.get("industry_trends", "")
525
+ if industry_trends:
526
+ _sec(ws, r, "Industry Trends", 2); r += 1
527
+ c = ws.cell(row=r, column=1, value=industry_trends)
528
+ c.font = LBL_FONT; c.alignment = Alignment(wrap_text=True, vertical="top")
529
+ ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=4)
530
+ ws.row_dimensions[r].height = 120
531
+ r += 2
532
+
533
+ _widths(ws, [100])
534
+
535
+
536
+ def _sources_sheet(wb, data):
537
+ ws = wb.create_sheet("Sources")
538
+ _title(ws, "Sources", "All data sources and retrieval dates")
539
+ _hdr(ws, 4, ["#","URL","Description","Retrieved"])
540
+ r = 5
541
+ for i, src in enumerate(data.get("sources",[]), start=1):
542
+ ws.cell(row=r, column=1, value=i).alignment = CENTER
543
+ url = src.get("url","")
544
+ uc = ws.cell(row=r, column=2, value=url)
545
+ uc.font = Font(name="Calibri", size=10, color="0563C1", underline="single")
546
+ uc.alignment = LEFT
547
+ if url:
548
+ try: uc.hyperlink = url
549
+ except: pass
550
+ ws.cell(row=r, column=3, value=src.get("description","")).alignment = LEFT
551
+ ws.cell(row=r, column=4, value=src.get("retrieved","")).alignment = CENTER
552
+ for col in range(1,5): ws.cell(row=r,column=col).border = THIN
553
+ ws.row_dimensions[r].height = 28; r += 1
554
+ _widths(ws, [6,70,50,16])
555
+
556
+
557
+ # ── Orchestrator ──────────────────────────────────────────────────────────────
558
+
559
+ def build_workbook(data: dict, output_path: str) -> None:
560
+ wb = Workbook()
561
+ wb.remove(wb.active)
562
+ _summary_sheet(wb, data)
563
+ _income_sheet(wb, data)
564
+ _balance_sheet(wb, data)
565
+ _cashflow_sheet(wb, data)
566
+ _ratios_sheet(wb, data)
567
+ _dupont_sheet(wb, data)
568
+ _dcf_sheet(wb, data)
569
+ _growth_sheet(wb, data)
570
+ _peers_sheet(wb, data)
571
+ _swot_sheet(wb, data)
572
+ _narrative_sheet(wb, data)
573
+ _sources_sheet(wb, data)
574
+ wb.save(output_path)
575
+
576
+
577
+ if __name__ == "__main__":
578
+ import argparse
579
+ p = argparse.ArgumentParser()
580
+ p.add_argument("--input", "-i", required=True)
581
+ p.add_argument("--output", "-o", required=True)
582
+ args = p.parse_args()
583
+ with open(args.input, encoding="utf-8") as f:
584
+ data = json.load(f)
585
+ Path(args.output).parent.mkdir(parents=True, exist_ok=True)
586
+ build_workbook(data, args.output)
587
+ print(f"Wrote: {args.output}")
pipeline/resolver.py CHANGED
@@ -1,346 +1,354 @@
1
- """
2
- pipeline/resolver.py β€” Resolve a company name or ticker to structured metadata.
3
-
4
- Source priority:
5
- 1. yfinance (free, no key, excellent Indian stock support)
6
- 2. FMP API (if key available)
7
- 3. Mark as unlisted with clear message
8
- """
9
- from __future__ import annotations
10
-
11
- import sys
12
- from pathlib import Path
13
- from typing import Optional
14
-
15
- sys.path.insert(0, str(Path(__file__).parent.parent))
16
- from config import FMP_API_KEY, FMP_BASE, REQUEST_TIMEOUT
17
-
18
- import requests
19
-
20
-
21
- # ── CompanyMeta ───────────────────────────────────────────────────────────────
22
-
23
- class CompanyMeta:
24
- def __init__(self, name, ticker, exchange, currency, sector, industry,
25
- country, description, website, mkt_cap, share_price, is_listed,
26
- source_note="", fiscal_year_end="March"):
27
- self.name = name
28
- self.ticker = ticker
29
- self.exchange = exchange
30
- self.currency = currency
31
- self.sector = sector
32
- self.industry = industry
33
- self.country = country
34
- self.description = description
35
- self.website = website
36
- self.mkt_cap = mkt_cap
37
- self.share_price = share_price
38
- self.is_listed = is_listed
39
- self.source_note = source_note
40
- self.fiscal_year_end = fiscal_year_end
41
-
42
- def to_dict(self):
43
- return self.__dict__.copy()
44
-
45
-
46
- # ── Indian company name β†’ NSE ticker map ─────────────────────────────────────
47
-
48
- INDIAN_TICKERS = {
49
- # IT
50
- "tcs": "TCS.NS", "tata consultancy": "TCS.NS", "tata consultancy services": "TCS.NS",
51
- "infosys": "INFY.NS", "infy": "INFY.NS",
52
- "wipro": "WIPRO.NS",
53
- "hcl": "HCLTECH.NS", "hcl tech": "HCLTECH.NS", "hcl technologies": "HCLTECH.NS",
54
- "tech mahindra": "TECHM.NS", "techm": "TECHM.NS",
55
- "ltimindtree": "LTIM.NS", "lti mindtree": "LTIM.NS",
56
- "mphasis": "MPHASIS.NS",
57
- "persistent": "PERSISTENT.NS",
58
- "coforge": "COFORGE.NS",
59
- # Banking
60
- "hdfc bank": "HDFCBANK.NS", "hdfcbank": "HDFCBANK.NS",
61
- "icici bank": "ICICIBANK.NS", "icicibank": "ICICIBANK.NS",
62
- "axis bank": "AXISBANK.NS", "axisbank": "AXISBANK.NS",
63
- "sbi": "SBIN.NS", "state bank": "SBIN.NS", "state bank of india": "SBIN.NS",
64
- "kotak": "KOTAKBANK.NS", "kotak bank": "KOTAKBANK.NS", "kotak mahindra bank": "KOTAKBANK.NS",
65
- "yes bank": "YESBANK.NS",
66
- "indusind bank": "INDUSINDBK.NS",
67
- "bank of baroda": "BANKBARODA.NS",
68
- "pnb": "PNB.NS", "punjab national bank": "PNB.NS",
69
- "canara bank": "CANARABANK.NS",
70
- # Conglomerates / Energy
71
- "reliance": "RELIANCE.NS", "reliance industries": "RELIANCE.NS", "ril": "RELIANCE.NS",
72
- "tata": "TATAMOTORS.NS",
73
- "adani enterprises": "ADANIENT.NS", "adani ports": "ADANIPORTS.NS",
74
- "adani green": "ADANIGREEN.NS", "adani total gas": "ATGL.NS",
75
- "ongc": "ONGC.NS",
76
- "oil and natural gas": "ONGC.NS",
77
- "ioc": "IOC.NS", "indian oil": "IOC.NS",
78
- "bpcl": "BPCL.NS", "bharat petroleum": "BPCL.NS",
79
- "coal india": "COALINDIA.NS",
80
- # FMCG
81
- "hindustan unilever": "HINDUNILVR.NS", "hul": "HINDUNILVR.NS",
82
- "itc": "ITC.NS",
83
- "nestle india": "NESTLEIND.NS", "nestle": "NESTLEIND.NS",
84
- "britannia": "BRITANNIA.NS",
85
- "dabur": "DABUR.NS",
86
- "marico": "MARICO.NS",
87
- "godrej consumer": "GODREJCP.NS", "godrej": "GODREJCP.NS",
88
- "emami": "EMAMILTD.NS",
89
- # Pharma
90
- "sun pharma": "SUNPHARMA.NS", "sun pharmaceutical": "SUNPHARMA.NS",
91
- "cipla": "CIPLA.NS",
92
- "dr reddy": "DRREDDY.NS", "dr reddys": "DRREDDY.NS",
93
- "divi's labs": "DIVISLAB.NS", "divis": "DIVISLAB.NS",
94
- "aurobindo": "AUROPHARMA.NS",
95
- "lupin": "LUPIN.NS",
96
- "torrent pharma": "TORNTPHARM.NS",
97
- # Auto
98
- "maruti": "MARUTI.NS", "maruti suzuki": "MARUTI.NS",
99
- "tata motors": "TATAMOTORS.NS",
100
- "m&m": "M&M.NS", "mahindra": "M&M.NS", "mahindra and mahindra": "M&M.NS",
101
- "hero motocorp": "HEROMOTOCO.NS", "hero": "HEROMOTOCO.NS",
102
- "bajaj auto": "BAJAJ-AUTO.NS",
103
- "eicher": "EICHERMOT.NS", "royal enfield": "EICHERMOT.NS",
104
- "tvs motor": "TVSMOTOR.NS",
105
- # Finance (non-bank)
106
- "bajaj finance": "BAJFINANCE.NS",
107
- "bajaj finserv": "BAJAJFINSV.NS",
108
- "hdfc life": "HDFCLIFE.NS",
109
- "sbi life": "SBILIFE.NS",
110
- "lic": "LICI.NS",
111
- "icici prudential": "ICICIPRULI.NS",
112
- "muthoot": "MUTHOOTFIN.NS",
113
- # Infra / Utilities
114
- "larsen": "LT.NS", "l&t": "LT.NS", "larsen and toubro": "LT.NS",
115
- "ntpc": "NTPC.NS",
116
- "power grid": "POWERGRID.NS",
117
- "tata power": "TATAPOWER.NS",
118
- "adani power": "ADANIPOWER.NS",
119
- # Cement / Materials
120
- "ultratech": "ULTRACEMCO.NS", "ultratech cement": "ULTRACEMCO.NS",
121
- "shree cement": "SHREECEM.NS",
122
- "ambuja": "AMBUJACEM.NS",
123
- "acc": "ACC.NS",
124
- "grasim": "GRASIM.NS",
125
- "tata steel": "TATASTEEL.NS",
126
- "jsw steel": "JSWSTEEL.NS",
127
- "hindalco": "HINDALCO.NS",
128
- "vedanta": "VEDL.NS",
129
- "jspl": "JINDALSTEL.NS",
130
- # Paints / Consumer
131
- "asian paints": "ASIANPAINT.NS",
132
- "berger paints": "BERGEPAINT.NS",
133
- "pidilite": "PIDILITIND.NS",
134
- "havells": "HAVELLS.NS",
135
- "voltas": "VOLTAS.NS",
136
- "titan": "TITAN.NS",
137
- "tanishq": "TITAN.NS",
138
- # Retail / Consumer
139
- "dmart": "DMART.NS", "avenue supermarts": "DMART.NS",
140
- "trent": "TRENT.NS",
141
- "zomato": "ZOMATO.NS",
142
- "nykaa": "NYKAA.NS",
143
- "policybazaar": "POLICYBZR.NS",
144
- # Telecom
145
- "airtel": "BHARTIARTL.NS", "bharti airtel": "BHARTIARTL.NS",
146
- "jio": "RELIANCE.NS", # Jio is part of Reliance
147
- # IT Infrastructure
148
- "hcl infosystems": "HCLISEC.NS",
149
- "dixon": "DIXON.NS",
150
- }
151
-
152
-
153
- def _guess_ticker(query: str) -> Optional[str]:
154
- """Try to find an Indian NSE ticker for a given name/query."""
155
- q = query.lower().strip()
156
- # Exact match
157
- if q in INDIAN_TICKERS:
158
- return INDIAN_TICKERS[q]
159
- # Partial match
160
- for key, ticker in INDIAN_TICKERS.items():
161
- if key in q or q in key:
162
- return ticker
163
- return None
164
-
165
-
166
- # ── yfinance resolution ───────────────────────────────────────────────────────
167
-
168
- def _yf_resolve(ticker: str, log_fn=None) -> Optional[CompanyMeta]:
169
- """Try to resolve a ticker via yfinance. Returns CompanyMeta or None."""
170
- def log(msg):
171
- if log_fn: log_fn(msg)
172
- try:
173
- import yfinance as yf
174
- t = yf.Ticker(ticker)
175
- info = t.info
176
- name = info.get("longName") or info.get("shortName", "")
177
- if not name:
178
- return None
179
- exchange_code = info.get("exchange", "")
180
- currency = info.get("currency", "INR")
181
- exchange_map = {
182
- "NSI": "NSE (India)", "BSE": "BSE (India)",
183
- "NMS": "NASDAQ (USA)", "NYQ": "NYSE (USA)",
184
- "LSE": "LSE (UK)",
185
- }
186
- exchange_display = exchange_map.get(exchange_code, exchange_code or "Unknown")
187
- country = info.get("country", "India")
188
- # Fiscal year end
189
- fy_end = "March" if currency == "INR" else "December"
190
-
191
- log(f" βœ“ yfinance: {name} | {ticker} | {exchange_display}")
192
- return CompanyMeta(
193
- name=name,
194
- ticker=ticker,
195
- exchange=exchange_display,
196
- currency=currency,
197
- sector=info.get("sector", ""),
198
- industry=info.get("industry", ""),
199
- country=country,
200
- description=info.get("longBusinessSummary", ""),
201
- website=info.get("website", ""),
202
- mkt_cap=info.get("marketCap"),
203
- share_price=info.get("currentPrice") or info.get("regularMarketPrice"),
204
- is_listed=True,
205
- source_note="Data sourced from Yahoo Finance (yfinance).",
206
- fiscal_year_end=fy_end,
207
- )
208
- except Exception as e:
209
- log(f" βœ— yfinance failed for {ticker}: {e}")
210
- return None
211
-
212
-
213
- # ── FMP resolution ────────────────────────────────────────────────────────────
214
-
215
- def _fmp_get(path: str, params: dict | None = None):
216
- if not FMP_API_KEY:
217
- return None
218
- url = f"{FMP_BASE}{path}"
219
- p = {"apikey": FMP_API_KEY}
220
- if params: p.update(params)
221
- try:
222
- r = requests.get(url, params=p, timeout=REQUEST_TIMEOUT)
223
- r.raise_for_status()
224
- return r.json()
225
- except Exception:
226
- return None
227
-
228
-
229
- def _fmp_resolve(query: str, log_fn=None) -> Optional[CompanyMeta]:
230
- """Try FMP search for global companies."""
231
- def log(msg):
232
- if log_fn: log_fn(msg)
233
- if not FMP_API_KEY:
234
- return None
235
- log(" β†’ Searching FMP …")
236
- # Direct profile
237
- profile = _fmp_get(f"/v3/profile/{query.upper()}")
238
- if not (profile and isinstance(profile, list) and profile[0].get("symbol")):
239
- # Search
240
- results = _fmp_get("/v3/search", {"query": query, "limit": 5})
241
- if results:
242
- best = results[0]
243
- profile = _fmp_get(f"/v3/profile/{best['symbol']}")
244
- if not (profile and isinstance(profile, list)):
245
- return None
246
- p = profile[0]
247
- if not p.get("companyName"):
248
- return None
249
- exchange = p.get("exchange", "") or p.get("exchangeShortName", "")
250
- exchange_map = {"NSE":"NSE (India)","BSE":"BSE (India)","NASDAQ":"NASDAQ (USA)","NYSE":"NYSE (USA)"}
251
- log(f" βœ“ FMP: {p.get('companyName')} ({p.get('symbol')})")
252
- return CompanyMeta(
253
- name=p.get("companyName",""),
254
- ticker=p.get("symbol",""),
255
- exchange=exchange_map.get(exchange, exchange),
256
- currency=p.get("currency","USD"),
257
- sector=p.get("sector",""),
258
- industry=p.get("industry",""),
259
- country=p.get("country",""),
260
- description=p.get("description",""),
261
- website=p.get("website",""),
262
- mkt_cap=p.get("mktCap"),
263
- share_price=p.get("price"),
264
- is_listed=True,
265
- source_note="Data sourced from FMP.",
266
- fiscal_year_end="December",
267
- )
268
-
269
-
270
- # ── yfinance search by company name ──────────────────────────────────────────
271
-
272
- def _yf_search(query: str, log_fn=None) -> Optional[CompanyMeta]:
273
- """Use yfinance's search to find a ticker from a company name."""
274
- def log(msg):
275
- if log_fn: log_fn(msg)
276
- try:
277
- import yfinance as yf
278
- log(f" β†’ yfinance search for '{query}' …")
279
- results = yf.Search(query, max_results=5)
280
- quotes = results.quotes
281
- if not quotes:
282
- return None
283
- # Take the first relevant result
284
- for q in quotes:
285
- symbol = q.get("symbol", "")
286
- qtype = q.get("quoteType", "")
287
- if qtype in ("EQUITY", "MUTUALFUND") and symbol:
288
- log(f" β†’ Found: {q.get('longname', symbol)} ({symbol})")
289
- return _yf_resolve(symbol, log_fn)
290
- return None
291
- except Exception as e:
292
- log(f" βœ— yfinance search failed: {e}")
293
- return None
294
-
295
-
296
- # ── Public resolve() ──────────────────────────────────────────────────────────
297
-
298
- def resolve(query: str, log_fn=None) -> CompanyMeta:
299
- """
300
- Resolve a company name or ticker to CompanyMeta.
301
- Source priority: Indian map β†’ yfinance direct β†’ yfinance search β†’ FMP β†’ unlisted
302
- """
303
- def log(msg):
304
- if log_fn: log_fn(msg)
305
-
306
- query = query.strip()
307
- log(f"Resolving: '{query}' …")
308
-
309
- # 1. Check Indian name map β†’ resolve via yfinance
310
- indian_ticker = _guess_ticker(query)
311
- if indian_ticker:
312
- log(f" β†’ Mapped to Indian ticker: {indian_ticker}")
313
- meta = _yf_resolve(indian_ticker, log_fn)
314
- if meta:
315
- return meta
316
-
317
- # 2. Try direct yfinance (query as-is, and with .NS suffix)
318
- for candidate in [query, query.upper(), f"{query.upper()}.NS", f"{query.upper()}.BO"]:
319
- meta = _yf_resolve(candidate, log_fn)
320
- if meta:
321
- return meta
322
-
323
- # 3. yfinance search
324
- meta = _yf_search(query, log_fn)
325
- if meta:
326
- return meta
327
-
328
- # 4. FMP search (for global companies)
329
- meta = _fmp_resolve(query, log_fn)
330
- if meta:
331
- return meta
332
-
333
- # 5. Unlisted fallback
334
- log(f" ⚠ '{query}' not found in any public market database.")
335
- log(" β†’ Treating as unlisted / private company.")
336
- return CompanyMeta(
337
- name=query, ticker="UNLISTED", exchange="Unlisted / Private",
338
- currency="INR", sector="Unknown", industry="Unknown",
339
- country="India", description="", website="",
340
- mkt_cap=None, share_price=None, is_listed=False,
341
- source_note=(
342
- f"'{query}' could not be found. It may be unlisted, private, or use a different name. "
343
- "Try using the exact BSE/NSE ticker (e.g. RELIANCE, TCS, HDFCBANK)."
344
- ),
345
- fiscal_year_end="March",
346
- )
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline/resolver.py β€” Resolve a company name or ticker to structured metadata.
3
+
4
+ Source priority:
5
+ 1. yahooquery (free, no key, excellent Indian stock support)
6
+ 2. FMP API (if key available)
7
+ 3. Mark as unlisted with clear message
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ sys.path.insert(0, str(Path(__file__).parent.parent))
16
+ from config import FMP_API_KEY, FMP_BASE, REQUEST_TIMEOUT
17
+
18
+ import requests
19
+
20
+
21
+ # ── CompanyMeta ───────────────────────────────────────────────────────────────
22
+
23
+ class CompanyMeta:
24
+ def __init__(self, name, ticker, exchange, currency, sector, industry,
25
+ country, description, website, mkt_cap, share_price, is_listed,
26
+ source_note="", fiscal_year_end="March"):
27
+ self.name = name
28
+ self.ticker = ticker
29
+ self.exchange = exchange
30
+ self.currency = currency
31
+ self.sector = sector
32
+ self.industry = industry
33
+ self.country = country
34
+ self.description = description
35
+ self.website = website
36
+ self.mkt_cap = mkt_cap
37
+ self.share_price = share_price
38
+ self.is_listed = is_listed
39
+ self.source_note = source_note
40
+ self.fiscal_year_end = fiscal_year_end
41
+
42
+ def to_dict(self):
43
+ return self.__dict__.copy()
44
+
45
+
46
+ # ── Indian company name β†’ NSE ticker map ─────────────────────────────────────
47
+
48
+ INDIAN_TICKERS = {
49
+ # IT
50
+ "tcs": "TCS.NS", "tata consultancy": "TCS.NS", "tata consultancy services": "TCS.NS",
51
+ "infosys": "INFY.NS", "infy": "INFY.NS",
52
+ "wipro": "WIPRO.NS",
53
+ "hcl": "HCLTECH.NS", "hcl tech": "HCLTECH.NS", "hcl technologies": "HCLTECH.NS",
54
+ "tech mahindra": "TECHM.NS", "techm": "TECHM.NS",
55
+ "ltimindtree": "LTIM.NS", "lti mindtree": "LTIM.NS",
56
+ "mphasis": "MPHASIS.NS",
57
+ "persistent": "PERSISTENT.NS",
58
+ "coforge": "COFORGE.NS",
59
+ # Banking
60
+ "hdfc bank": "HDFCBANK.NS", "hdfcbank": "HDFCBANK.NS",
61
+ "icici bank": "ICICIBANK.NS", "icicibank": "ICICIBANK.NS",
62
+ "axis bank": "AXISBANK.NS", "axisbank": "AXISBANK.NS",
63
+ "sbi": "SBIN.NS", "state bank": "SBIN.NS", "state bank of india": "SBIN.NS",
64
+ "kotak": "KOTAKBANK.NS", "kotak bank": "KOTAKBANK.NS", "kotak mahindra bank": "KOTAKBANK.NS",
65
+ "yes bank": "YESBANK.NS",
66
+ "indusind bank": "INDUSINDBK.NS",
67
+ "bank of baroda": "BANKBARODA.NS",
68
+ "pnb": "PNB.NS", "punjab national bank": "PNB.NS",
69
+ "canara bank": "CANARABANK.NS",
70
+ # Conglomerates / Energy
71
+ "reliance": "RELIANCE.NS", "reliance industries": "RELIANCE.NS", "ril": "RELIANCE.NS",
72
+ "tata": "TATAMOTORS.NS",
73
+ "adani enterprises": "ADANIENT.NS", "adani ports": "ADANIPORTS.NS",
74
+ "adani green": "ADANIGREEN.NS", "adani total gas": "ATGL.NS",
75
+ "ongc": "ONGC.NS",
76
+ "oil and natural gas": "ONGC.NS",
77
+ "ioc": "IOC.NS", "indian oil": "IOC.NS",
78
+ "bpcl": "BPCL.NS", "bharat petroleum": "BPCL.NS",
79
+ "coal india": "COALINDIA.NS",
80
+ # FMCG
81
+ "hindustan unilever": "HINDUNILVR.NS", "hul": "HINDUNILVR.NS",
82
+ "itc": "ITC.NS",
83
+ "nestle india": "NESTLEIND.NS", "nestle": "NESTLEIND.NS",
84
+ "britannia": "BRITANNIA.NS",
85
+ "dabur": "DABUR.NS",
86
+ "marico": "MARICO.NS",
87
+ "godrej consumer": "GODREJCP.NS", "godrej": "GODREJCP.NS",
88
+ "emami": "EMAMILTD.NS",
89
+ # Pharma
90
+ "sun pharma": "SUNPHARMA.NS", "sun pharmaceutical": "SUNPHARMA.NS",
91
+ "cipla": "CIPLA.NS",
92
+ "dr reddy": "DRREDDY.NS", "dr reddys": "DRREDDY.NS",
93
+ "divi's labs": "DIVISLAB.NS", "divis": "DIVISLAB.NS",
94
+ "aurobindo": "AUROPHARMA.NS",
95
+ "lupin": "LUPIN.NS",
96
+ "torrent pharma": "TORNTPHARM.NS",
97
+ # Auto
98
+ "maruti": "MARUTI.NS", "maruti suzuki": "MARUTI.NS",
99
+ "tata motors": "TATAMOTORS.NS",
100
+ "m&m": "M&M.NS", "mahindra": "M&M.NS", "mahindra and mahindra": "M&M.NS",
101
+ "hero motocorp": "HEROMOTOCO.NS", "hero": "HEROMOTOCO.NS",
102
+ "bajaj auto": "BAJAJ-AUTO.NS",
103
+ "eicher": "EICHERMOT.NS", "royal enfield": "EICHERMOT.NS",
104
+ "tvs motor": "TVSMOTOR.NS",
105
+ # Finance (non-bank)
106
+ "bajaj finance": "BAJFINANCE.NS",
107
+ "bajaj finserv": "BAJAJFINSV.NS",
108
+ "hdfc life": "HDFCLIFE.NS",
109
+ "sbi life": "SBILIFE.NS",
110
+ "lic": "LICI.NS",
111
+ "icici prudential": "ICICIPRULI.NS",
112
+ "muthoot": "MUTHOOTFIN.NS",
113
+ # Infra / Utilities
114
+ "larsen": "LT.NS", "l&t": "LT.NS", "larsen and toubro": "LT.NS",
115
+ "ntpc": "NTPC.NS",
116
+ "power grid": "POWERGRID.NS",
117
+ "tata power": "TATAPOWER.NS",
118
+ "adani power": "ADANIPOWER.NS",
119
+ # Cement / Materials
120
+ "ultratech": "ULTRACEMCO.NS", "ultratech cement": "ULTRACEMCO.NS",
121
+ "shree cement": "SHREECEM.NS",
122
+ "ambuja": "AMBUJACEM.NS",
123
+ "acc": "ACC.NS",
124
+ "grasim": "GRASIM.NS",
125
+ "tata steel": "TATASTEEL.NS",
126
+ "jsw steel": "JSWSTEEL.NS",
127
+ "hindalco": "HINDALCO.NS",
128
+ "vedanta": "VEDL.NS",
129
+ "jspl": "JINDALSTEL.NS",
130
+ # Paints / Consumer
131
+ "asian paints": "ASIANPAINT.NS",
132
+ "berger paints": "BERGEPAINT.NS",
133
+ "pidilite": "PIDILITIND.NS",
134
+ "havells": "HAVELLS.NS",
135
+ "voltas": "VOLTAS.NS",
136
+ "titan": "TITAN.NS",
137
+ "tanishq": "TITAN.NS",
138
+ # Retail / Consumer
139
+ "dmart": "DMART.NS", "avenue supermarts": "DMART.NS",
140
+ "trent": "TRENT.NS",
141
+ "zomato": "ZOMATO.NS",
142
+ "nykaa": "NYKAA.NS",
143
+ "policybazaar": "POLICYBZR.NS",
144
+ # Telecom
145
+ "airtel": "BHARTIARTL.NS", "bharti airtel": "BHARTIARTL.NS",
146
+ "jio": "RELIANCE.NS", # Jio is part of Reliance
147
+ # IT Infrastructure
148
+ "hcl infosystems": "HCLISEC.NS",
149
+ "dixon": "DIXON.NS",
150
+ }
151
+
152
+
153
+ def _guess_ticker(query: str) -> Optional[str]:
154
+ """Try to find an Indian NSE ticker for a given name/query."""
155
+ q = query.lower().strip()
156
+ # Exact match
157
+ if q in INDIAN_TICKERS:
158
+ return INDIAN_TICKERS[q]
159
+ # Partial match
160
+ for key, ticker in INDIAN_TICKERS.items():
161
+ if key in q or q in key:
162
+ return ticker
163
+ return None
164
+
165
+
166
+ # ── yahooquery resolution ───────────────────────────────────────────────────────
167
+
168
+ def _yq_resolve(ticker: str, log_fn=None) -> Optional[CompanyMeta]:
169
+ """Try to resolve a ticker via yahooquery. Returns CompanyMeta or None."""
170
+ def log(msg):
171
+ if log_fn: log_fn(msg)
172
+ try:
173
+ import yahooquery as yq
174
+ t = yq.Ticker(ticker)
175
+
176
+ # Yahooquery returns dicts with ticker as the key
177
+ price_data = t.price.get(ticker, {})
178
+ profile_data = t.summary_profile.get(ticker, {})
179
+
180
+ if isinstance(price_data, str) or isinstance(profile_data, str):
181
+ # Yahooquery sometimes returns strings like "No fundamentals data found"
182
+ log(f" βœ— yahooquery returned no data for {ticker}")
183
+ return None
184
+
185
+ name = price_data.get("longName") or price_data.get("shortName", "")
186
+ if not name:
187
+ return None
188
+
189
+ exchange_code = price_data.get("exchange", "")
190
+ currency = price_data.get("currency", "INR")
191
+ exchange_map = {
192
+ "NSI": "NSE (India)", "BSE": "BSE (India)",
193
+ "NMS": "NASDAQ (USA)", "NYQ": "NYSE (USA)",
194
+ "LSE": "LSE (UK)",
195
+ }
196
+ exchange_display = exchange_map.get(exchange_code, exchange_code or "Unknown")
197
+ country = profile_data.get("country", "India")
198
+ # Fiscal year end
199
+ fy_end = "March" if currency == "INR" else "December"
200
+
201
+ log(f" βœ“ yahooquery: {name} | {ticker} | {exchange_display}")
202
+ return CompanyMeta(
203
+ name=name,
204
+ ticker=ticker,
205
+ exchange=exchange_display,
206
+ currency=currency,
207
+ sector=profile_data.get("sector", ""),
208
+ industry=profile_data.get("industry", ""),
209
+ country=country,
210
+ description=profile_data.get("longBusinessSummary", ""),
211
+ website=profile_data.get("website", ""),
212
+ mkt_cap=price_data.get("marketCap"),
213
+ share_price=price_data.get("regularMarketPrice"),
214
+ is_listed=True,
215
+ source_note="Data sourced from Yahoo Finance (yahooquery).",
216
+ fiscal_year_end=fy_end,
217
+ )
218
+ except Exception as e:
219
+ log(f" βœ— yahooquery failed for {ticker}: {e}")
220
+ return None
221
+
222
+
223
+ # ── FMP resolution ────────────────────────────────────────────────────────────
224
+
225
+ def _fmp_get(path: str, params: dict | None = None):
226
+ if not FMP_API_KEY:
227
+ return None
228
+ url = f"{FMP_BASE}{path}"
229
+ p = {"apikey": FMP_API_KEY}
230
+ if params: p.update(params)
231
+ try:
232
+ r = requests.get(url, params=p, timeout=REQUEST_TIMEOUT)
233
+ r.raise_for_status()
234
+ return r.json()
235
+ except Exception:
236
+ return None
237
+
238
+
239
+ def _fmp_resolve(query: str, log_fn=None) -> Optional[CompanyMeta]:
240
+ """Try FMP search for global companies."""
241
+ def log(msg):
242
+ if log_fn: log_fn(msg)
243
+ if not FMP_API_KEY:
244
+ return None
245
+ log(" β†’ Searching FMP …")
246
+ # Direct profile
247
+ profile = _fmp_get(f"/v3/profile/{query.upper()}")
248
+ if not (profile and isinstance(profile, list) and profile[0].get("symbol")):
249
+ # Search
250
+ results = _fmp_get("/v3/search", {"query": query, "limit": 5})
251
+ if results:
252
+ best = results[0]
253
+ profile = _fmp_get(f"/v3/profile/{best['symbol']}")
254
+ if not (profile and isinstance(profile, list)):
255
+ return None
256
+ p = profile[0]
257
+ if not p.get("companyName"):
258
+ return None
259
+ exchange = p.get("exchange", "") or p.get("exchangeShortName", "")
260
+ exchange_map = {"NSE":"NSE (India)","BSE":"BSE (India)","NASDAQ":"NASDAQ (USA)","NYSE":"NYSE (USA)"}
261
+ log(f" βœ“ FMP: {p.get('companyName')} ({p.get('symbol')})")
262
+ return CompanyMeta(
263
+ name=p.get("companyName",""),
264
+ ticker=p.get("symbol",""),
265
+ exchange=exchange_map.get(exchange, exchange),
266
+ currency=p.get("currency","USD"),
267
+ sector=p.get("sector",""),
268
+ industry=p.get("industry",""),
269
+ country=p.get("country",""),
270
+ description=p.get("description",""),
271
+ website=p.get("website",""),
272
+ mkt_cap=p.get("mktCap"),
273
+ share_price=p.get("price"),
274
+ is_listed=True,
275
+ source_note="Data sourced from FMP.",
276
+ fiscal_year_end="December",
277
+ )
278
+
279
+
280
+ # ── yahooquery search by company name ──────────────────────────────────────────
281
+
282
+ def _yq_search(query: str, log_fn=None) -> Optional[CompanyMeta]:
283
+ """Use yahooquery's search to find a ticker from a company name."""
284
+ def log(msg):
285
+ if log_fn: log_fn(msg)
286
+ try:
287
+ import yahooquery as yq
288
+ log(f" β†’ yahooquery search for '{query}' …")
289
+ results = yq.search(query)
290
+ quotes = results.get("quotes", [])
291
+ if not quotes:
292
+ return None
293
+ # Take the first relevant result
294
+ for q in quotes:
295
+ symbol = q.get("symbol", "")
296
+ qtype = q.get("quoteType", "")
297
+ if qtype in ("EQUITY", "MUTUALFUND") and symbol:
298
+ log(f" β†’ Found: {q.get('longname', symbol)} ({symbol})")
299
+ return _yq_resolve(symbol, log_fn)
300
+ return None
301
+ except Exception as e:
302
+ log(f" βœ— yahooquery search failed: {e}")
303
+ return None
304
+
305
+
306
+ # ── Public resolve() ──────────────────────────────────────────────────────────
307
+
308
+ def resolve(query: str, log_fn=None) -> CompanyMeta:
309
+ """
310
+ Resolve a company name or ticker to CompanyMeta.
311
+ Source priority: Indian map β†’ yahooquery direct β†’ yahooquery search β†’ FMP β†’ unlisted
312
+ """
313
+ def log(msg):
314
+ if log_fn: log_fn(msg)
315
+
316
+ query = query.strip()
317
+ log(f"Resolving: '{query}' …")
318
+
319
+ # 1. Check Indian name map β†’ resolve via yahooquery
320
+ indian_ticker = _guess_ticker(query)
321
+ if indian_ticker:
322
+ log(f" βœ“ Matches known Indian map β†’ {indian_ticker}")
323
+ meta = _yq_resolve(indian_ticker, log_fn)
324
+ if meta: return meta
325
+
326
+ # 2. Try direct yahooquery (query as-is, and with .NS suffix)
327
+ for candidate in [query.upper(), f"{query.upper()}.NS", f"{query.upper()}.BO"]:
328
+ log(f" β†’ Trying direct ticker: {candidate}")
329
+ meta = _yq_resolve(candidate, log_fn)
330
+ if meta: return meta
331
+
332
+ # 3. yahooquery search
333
+ meta = _yq_search(query, log_fn)
334
+ if meta: return meta
335
+
336
+ # 4. FMP Search (for global companies)
337
+ meta = _fmp_resolve(query, log_fn)
338
+ if meta:
339
+ return meta
340
+
341
+ # 5. Unlisted fallback
342
+ log(f" ⚠ '{query}' not found in any public market database.")
343
+ log(" β†’ Treating as unlisted / private company.")
344
+ return CompanyMeta(
345
+ name=query, ticker="UNLISTED", exchange="Unlisted / Private",
346
+ currency="INR", sector="Unknown", industry="Unknown",
347
+ country="India", description="", website="",
348
+ mkt_cap=None, share_price=None, is_listed=False,
349
+ source_note=(
350
+ f"'{query}' could not be found. It may be unlisted, private, or use a different name. "
351
+ "Try using the exact BSE/NSE ticker (e.g. RELIANCE, TCS, HDFCBANK)."
352
+ ),
353
+ fiscal_year_end="March",
354
+ )
web_app/app.py CHANGED
@@ -1,588 +1,588 @@
1
- """
2
- web_app/app.py β€” Cloud-hosted Streamlit Portal for Financial Analysis.
3
-
4
- Provides a rich dark UI dashboard to scrape, compute, analyze and download reports
5
- for Excel, Word, and PowerPoint formats using user-supplied LLM credentials.
6
- """
7
- import os
8
- import sys
9
- import time
10
- import datetime
11
- from pathlib import Path
12
- import streamlit as st
13
-
14
- # Setup sys.path so local packages can be resolved from parent directory
15
- PARENT_DIR = Path(__file__).parent.parent
16
- sys.path.insert(0, str(PARENT_DIR))
17
-
18
- # Import builders and pipeline
19
- from pipeline.resolver import resolve
20
- from pipeline.fetcher import fetch_all
21
- from pipeline.peer_selector import select_peers
22
- from pipeline.calculator import calculate
23
- from pipeline.analyst import generate_narrative
24
- from builders.build_excel import build_workbook
25
- from builders.build_docx import build_document
26
- from builders.build_pptx import build_presentation
27
- from web_app.database import save_audit_log, get_audit_logs
28
-
29
- # Streamlit Page Config
30
- st.set_page_config(
31
- page_title="Financial Statement Analyzer Cloud Portal",
32
- page_icon="πŸ“ˆ",
33
- layout="wide",
34
- initial_sidebar_state="expanded"
35
- )
36
-
37
- # Dark Theme Glassmorphism Custom CSS Injection
38
- st.markdown("""
39
- <style>
40
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap');
41
-
42
- html, body, [class*="css"] {
43
- font-family: 'Inter', sans-serif;
44
- }
45
-
46
- .main {
47
- background-color: #0A0F1D;
48
- color: #F3F4F6;
49
- }
50
-
51
- /* Sidebar styling */
52
- [data-testid="stSidebar"] {
53
- background-color: #0F162A;
54
- border-right: 1px solid #1E293B;
55
- }
56
-
57
- /* Title and Subtitle */
58
- .dashboard-title {
59
- font-size: 2.5rem;
60
- font-weight: 700;
61
- background: linear-gradient(135deg, #60A5FA, #34D399);
62
- -webkit-background-clip: text;
63
- -webkit-text-fill-color: transparent;
64
- margin-bottom: 0.2rem;
65
- }
66
-
67
- .dashboard-subtitle {
68
- font-size: 1.1rem;
69
- font-weight: 300;
70
- color: #9CA3AF;
71
- margin-bottom: 2rem;
72
- }
73
-
74
- /* Glowing card containers */
75
- .glass-card {
76
- background: rgba(30, 41, 59, 0.45);
77
- border: 1px solid rgba(255, 255, 255, 0.08);
78
- border-radius: 16px;
79
- padding: 1.5rem;
80
- backdrop-filter: blur(12px);
81
- margin-bottom: 1.5rem;
82
- box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
83
- }
84
-
85
- .status-log {
86
- background-color: #0D1117 !important;
87
- border: 1px solid #1F242C !important;
88
- border-radius: 8px !important;
89
- color: #C9D1D9 !important;
90
- font-family: 'Consolas', monospace !important;
91
- padding: 12px;
92
- max-height: 380px;
93
- overflow-y: auto;
94
- }
95
-
96
- /* Primary buttons styling */
97
- div.stButton > button:first-child {
98
- background: linear-gradient(135deg, #3B82F6, #10B981);
99
- border: none;
100
- color: white;
101
- font-weight: 600;
102
- padding: 0.6rem 2rem;
103
- border-radius: 8px;
104
- transition: transform 0.2s, box-shadow 0.2s;
105
- box-shadow: 0 0 15px rgba(59, 130, 246, 0.4);
106
- }
107
-
108
- div.stButton > button:first-child:hover {
109
- transform: translateY(-2px);
110
- box-shadow: 0 0 25px rgba(16, 185, 129, 0.6);
111
- color: white;
112
- }
113
-
114
- /* Checkbox layouts styling */
115
- .stCheckbox > label {
116
- color: #D1D5DB !important;
117
- }
118
- </style>
119
- """, unsafe_allow_html=True)
120
-
121
- # ── Sidebar β€” Connect Your AI ──────────────────────────────────────────
122
- st.sidebar.markdown("""
123
- <h2 style='color:#60A5FA;margin-top:0;'>πŸ€– Connect Your AI</h2>
124
- <p style='font-size:0.8rem;color:#9CA3AF;'>
125
- Bring your own key. Keys live only in your browser session β€” never stored on the server.
126
- </p>
127
- """, unsafe_allow_html=True)
128
-
129
- # ── FREE OPTIONS ───────────────────────────────────────────────────────
130
- st.sidebar.markdown("""
131
- <div style='background:rgba(16,185,129,0.08);border:1px solid rgba(16,185,129,0.25);
132
- border-radius:8px;padding:0.6rem 0.85rem;margin-bottom:0.5rem;'>
133
- <span style='font-size:0.75rem;font-weight:700;color:#34D399;letter-spacing:0.06em;'>πŸ†“ FREE β€” NO CREDIT CARD</span>
134
- </div>
135
- """, unsafe_allow_html=True)
136
-
137
- st.sidebar.markdown("""
138
- **NVIDIA NIM** *(Llama 3.1 405B / Nemotron)*
139
- <a href='https://build.nvidia.com/explore/discover' target='_blank' style='font-size:0.75rem;color:#34D399;'>Get free key β†’ build.nvidia.com</a>
140
- """, unsafe_allow_html=True)
141
- nvidia_key = st.sidebar.text_input(
142
- "NVIDIA API Key", type="password",
143
- placeholder="nvapi-…",
144
- value=os.environ.get("NVIDIA_API_KEY", ""),
145
- help="Free credits available. Sign up at build.nvidia.com.",
146
- key="inp_nvidia"
147
- )
148
-
149
- st.sidebar.markdown("""
150
- **Groq** *(Llama 3.3 70B β€” fast & free)*
151
- <a href='https://console.groq.com/keys' target='_blank' style='font-size:0.75rem;color:#34D399;'>Get free key β†’ console.groq.com</a>
152
- """, unsafe_allow_html=True)
153
- groq_key = st.sidebar.text_input(
154
- "Groq API Key", type="password",
155
- placeholder="gsk_…",
156
- value=os.environ.get("GROQ_API_KEY", ""),
157
- help="100% free. Sign up at console.groq.com β†’ API Keys. No credit card.",
158
- key="inp_groq"
159
- )
160
-
161
- st.sidebar.markdown("""
162
- **Google Gemini** *(Gemini 2.0 Flash β€” free tier)*
163
- <a href='https://aistudio.google.com/app/apikey' target='_blank' style='font-size:0.75rem;color:#34D399;'>Get free key β†’ aistudio.google.com</a>
164
- """, unsafe_allow_html=True)
165
- gemini_key = st.sidebar.text_input(
166
- "Gemini API Key", type="password",
167
- placeholder="AIza…",
168
- value=os.environ.get("GEMINI_API_KEY", ""),
169
- help="Free tier: 15 requests/min, 1M tokens/day. No billing needed.",
170
- key="inp_gemini"
171
- )
172
-
173
- st.sidebar.markdown("""
174
- **Hugging Face** *(Serverless API)*
175
- <a href='https://huggingface.co/settings/tokens' target='_blank' style='font-size:0.75rem;color:#34D399;'>Get free key β†’ huggingface.co</a>
176
- """, unsafe_allow_html=True)
177
-
178
- _env_hf = os.environ.get("HF_API_KEY", "")
179
- if _env_hf:
180
- hf_key = _env_hf
181
- st.sidebar.success("βœ… HF Key securely loaded from server secrets.")
182
- else:
183
- hf_key = st.sidebar.text_input(
184
- "Hugging Face API Key", type="password",
185
- placeholder="hf_…",
186
- help="Used for intelligent peer selection and DeepSeek analysis.",
187
- key="inp_hf"
188
- )
189
-
190
- # ── PAID OPTIONS ───────────────────────────────────────────────────────
191
- st.sidebar.markdown("""
192
- <div style='background:rgba(99,102,241,0.08);border:1px solid rgba(99,102,241,0.25);
193
- border-radius:8px;padding:0.6rem 0.85rem;margin:0.5rem 0;'>
194
- <span style='font-size:0.75rem;font-weight:700;color:#818CF8;letter-spacing:0.06em;'>πŸ’³ OPTIONAL β€” PAID APIS</span>
195
- </div>
196
- """, unsafe_allow_html=True)
197
-
198
- with st.sidebar.expander("Claude (Anthropic) β€” paid", expanded=False):
199
- st.markdown("<a href='https://console.anthropic.com/' target='_blank' style='font-size:0.75rem;color:#818CF8;'>Get key β†’ console.anthropic.com</a>", unsafe_allow_html=True)
200
- claude_key = st.text_input(
201
- "Claude API Key", type="password",
202
- placeholder="sk-ant-…",
203
- value=os.environ.get("CLAUDE_API_KEY", ""),
204
- key="inp_claude"
205
- )
206
-
207
- with st.sidebar.expander("ChatGPT (OpenAI) β€” paid", expanded=False):
208
- st.markdown("<a href='https://platform.openai.com/api-keys' target='_blank' style='font-size:0.75rem;color:#818CF8;'>Get key β†’ platform.openai.com</a>", unsafe_allow_html=True)
209
- openai_key = st.text_input(
210
- "OpenAI API Key", type="password",
211
- placeholder="sk-proj-…",
212
- value=os.environ.get("OPENAI_API_KEY", ""),
213
- key="inp_openai"
214
- )
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)
222
- st.markdown("<p class='dashboard-subtitle'>Multi-model AI insights scraper and report compilation portal</p>", unsafe_allow_html=True)
223
-
224
- # Left Column: Inputs
225
- # Right Column: Exporters & Progress
226
- col1, col2 = st.columns([1, 1.2])
227
-
228
- with col1:
229
- st.markdown("<div class='glass-card'>", unsafe_allow_html=True)
230
- st.subheader("Analysis Parameters")
231
-
232
- # ── Ticker Search & Suggestion Logic ──
233
- search_col1, search_col2 = st.columns([3, 1])
234
- with search_col1:
235
- search_query = st.text_input("Search Company Name or Ticker", placeholder="e.g. TCS, Reliance, AAPL, MSFT")
236
- with search_col2:
237
- st.markdown("<div style='margin-top:28px;'></div>", unsafe_allow_html=True)
238
- search_clicked = st.button("πŸ” Find", use_container_width=True)
239
-
240
- ticker_input = search_query # default if no suggestions
241
-
242
- if search_clicked and search_query:
243
- with st.spinner("Searching..."):
244
- try:
245
- import yfinance as yf
246
- res = yf.Search(search_query, max_results=8)
247
- quotes = [q for q in res.quotes if q.get("quoteType") in ("EQUITY", "MUTUALFUND", "ETF")]
248
- st.session_state["ticker_suggestions"] = quotes
249
- except Exception:
250
- st.session_state["ticker_suggestions"] = []
251
-
252
- if "ticker_suggestions" in st.session_state and st.session_state["ticker_suggestions"]:
253
- options = {}
254
- for q in st.session_state["ticker_suggestions"]:
255
- name = q.get('longname') or q.get('shortname') or q.get('symbol')
256
- label = f"{name} ({q['symbol']}) - {q.get('exchange', 'Unknown')}"
257
- options[label] = q['symbol']
258
-
259
- options[f"Use exact input: '{search_query}'"] = search_query
260
- selected_label = st.selectbox("Confirm Company:", list(options.keys()))
261
- ticker_input = options[selected_label]
262
-
263
- col_years, col_refresh = st.columns([1, 1])
264
- with col_years:
265
- analysis_years = st.selectbox("Analysis Horizon", options=[5, 7, 10], index=0)
266
- with col_refresh:
267
- force_refresh = st.checkbox("Bypass Cache (Force Scrape)", value=False)
268
-
269
- st.markdown("<b>Download Formats:</b>", unsafe_allow_html=True)
270
- chk_excel = st.checkbox("Excel Analysis Workbook (.xlsx)", value=True)
271
- chk_word = st.checkbox("Word Summary Report (.docx)", value=True)
272
- chk_pptx = st.checkbox("PowerPoint Pitch Deck (.pptx)", value=True)
273
-
274
- # ── AI Engine selection β€” auto-disable if key not supplied
275
- st.markdown("<b>AI Engines:</b>", unsafe_allow_html=True)
276
-
277
- _hint = "<span style='font-size:0.72rem;color:#9CA3AF;'>(add key in sidebar β†’)</span>"
278
-
279
- engines = []
280
-
281
- if st.checkbox(
282
- "πŸ†“ NVIDIA NIM β€” Llama 3.1 405B",
283
- value=bool(nvidia_key),
284
- disabled=not nvidia_key,
285
- help="Free credits. Get key at build.nvidia.com"
286
- ):
287
- engines.append("nvidia")
288
-
289
- if st.checkbox(
290
- "πŸ†“ Groq β€” Llama 3.3 70B",
291
- value=bool(groq_key),
292
- disabled=not groq_key,
293
- help="Free. Get key at console.groq.com"
294
- ):
295
- engines.append("groq")
296
-
297
- if st.checkbox(
298
- "πŸ†“ Hugging Face β€” DeepSeek-R1",
299
- value=bool(hf_key),
300
- disabled=not hf_key,
301
- help="Free via Hugging Face Inference API"
302
- ):
303
- engines.append("huggingface")
304
-
305
- if st.checkbox(
306
- "πŸ†“ Gemini 2.0 Flash (Google)",
307
- value=bool(gemini_key),
308
- disabled=not gemini_key,
309
- help="Free tier. Get key at aistudio.google.com"
310
- ):
311
- engines.append("gemini")
312
-
313
- if st.checkbox(
314
- "πŸ’³ Claude Haiku (Anthropic)",
315
- value=False,
316
- disabled=not claude_key,
317
- help="Paid. Add key in sidebar to enable."
318
- ):
319
- engines.append("claude")
320
-
321
- if st.checkbox(
322
- "πŸ’³ ChatGPT β€” GPT-4o mini (OpenAI)",
323
- value=False,
324
- disabled=not openai_key,
325
- help="Paid. Add key in sidebar to enable."
326
- ):
327
- engines.append("openai")
328
-
329
- if not groq_key and not gemini_key:
330
- st.info("πŸ’‘ Add a **free Groq key** in the sidebar to enable AI narratives.", icon="πŸ”‘")
331
-
332
- st.markdown("<br>", unsafe_allow_html=True)
333
- start_btn = st.button("Begin Financial Scrape & Analysis")
334
- st.markdown("</div>", unsafe_allow_html=True)
335
-
336
- with col2:
337
- st.markdown("<div class='glass-card'>", unsafe_allow_html=True)
338
- st.subheader("Execution Status & Logs")
339
-
340
- log_container = st.empty()
341
- download_container = st.empty()
342
-
343
- log_container.code("Ready to analyze. Input a company and click the button above.", language="text")
344
- st.markdown("</div>", 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.")
354
- else:
355
- # Inject credentials into all modules that cache them at import time
356
- import config
357
- import pipeline.analyst
358
- import pipeline.key_validator
359
-
360
- config.GEMINI_API_KEY = gemini_key
361
- config.CLAUDE_API_KEY = claude_key
362
- config.OPENAI_API_KEY = openai_key
363
- config.GROQ_API_KEY = groq_key
364
- config.NVIDIA_API_KEY = nvidia_key
365
- config.HF_API_KEY = hf_key
366
-
367
- os.environ["GEMINI_API_KEY"] = gemini_key
368
- os.environ["CLAUDE_API_KEY"] = claude_key
369
- os.environ["OPENAI_API_KEY"] = openai_key
370
- os.environ["NVIDIA_API_KEY"] = nvidia_key
371
- os.environ["HF_API_KEY"] = hf_key
372
-
373
- pipeline.analyst.GEMINI_API_KEY = gemini_key
374
- pipeline.analyst.CLAUDE_API_KEY = claude_key
375
- pipeline.analyst.OPENAI_API_KEY = openai_key
376
- pipeline.analyst.GROQ_API_KEY = groq_key
377
- pipeline.analyst.NVIDIA_API_KEY = nvidia_key
378
-
379
- pipeline.key_validator.GEMINI_API_KEY = gemini_key
380
- pipeline.key_validator.CLAUDE_API_KEY = claude_key
381
- pipeline.key_validator.OPENAI_API_KEY = openai_key
382
- pipeline.key_validator.GROQ_API_KEY = groq_key
383
- pipeline.key_validator.NVIDIA_API_KEY = nvidia_key
384
-
385
- # Push into environment for any sub-process
386
- for k, v in [("GEMINI_API_KEY", gemini_key), ("CLAUDE_API_KEY", claude_key),
387
- ("OPENAI_API_KEY", openai_key), ("GROQ_API_KEY", groq_key),
388
- ("NVIDIA_API_KEY", nvidia_key), ("HF_API_KEY", hf_key)]:
389
- if v: os.environ[k] = v
390
-
391
- # Validate that selected engines have keys
392
- failed_validation = False
393
- engine_key_map = {
394
- "nvidia": (nvidia_key, "NVIDIA"),
395
- "groq": (groq_key, "Groq"),
396
- "gemini": (gemini_key, "Gemini"),
397
- "claude": (claude_key, "Claude"),
398
- "openai": (openai_key, "ChatGPT"),
399
- "huggingface": (hf_key, "Hugging Face"),
400
- }
401
- for engine in engines:
402
- key, label = engine_key_map[engine]
403
- if not key:
404
- st.error(f"Please supply a {label} API Key or uncheck the {label} model.")
405
- failed_validation = True
406
-
407
- if not failed_validation:
408
- logs = []
409
-
410
- def log_fn(msg):
411
- logs.append(msg)
412
- log_container.code("\n".join(logs), language="text")
413
-
414
- try:
415
- log_fn("="*60)
416
- log_fn(f"Starting analysis for: '{ticker_input}'")
417
- log_fn(f"Time: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
418
- log_fn(f"Formats: " + ", ".join([f for f, ok in [("Excel", chk_excel), ("Word", chk_word), ("PowerPoint", chk_pptx)] if ok]))
419
- log_fn(f"AI Models: {engines or ['None (Data Only)']}")
420
- log_fn("="*60)
421
-
422
- # 1. Resolve Company Info
423
- log_fn("\n[1/7] Resolving company metadata …")
424
- meta = resolve(ticker_input, log_fn=log_fn)
425
- log_fn(f" βœ“ {meta.name} | {meta.ticker} | {meta.exchange}")
426
-
427
- # 2. Fetch Statements
428
- log_fn("\n[2/7] Scrape financial statements …")
429
- fin_data = fetch_all(meta, force_refresh=force_refresh, log_fn=log_fn)
430
-
431
- # 3. Peers Comparison Selection
432
- log_fn("\n[3/7] Benchmarking peer selection …")
433
- fin_data["peers"] = select_peers(meta, log_fn=log_fn)
434
-
435
- # 4. Math ratios calculations
436
- log_fn("\n[4/7] Computing financial ratios & DuPont decomposition …")
437
- fin_data = calculate(fin_data, meta.to_dict())
438
- log_fn(" βœ“ Completed ratio math computations")
439
-
440
- # 5. AI Synthesis Narrative
441
- log_fn("\n[5/7] Requesting AI Model narrative generation …")
442
- narrative = generate_narrative(fin_data, meta.to_dict(), engines, log_fn=log_fn)
443
- fin_data.update(narrative)
444
-
445
- # 6. Generate outputs
446
- log_fn("\n[6/7] Building output report files …")
447
- fin_data["company"] = {
448
- "name": meta.name, "ticker": meta.ticker, "exchange": meta.exchange,
449
- "currency": fin_data.get("currency", meta.currency),
450
- "units": fin_data.get("units", "millions"),
451
- "fiscal_year_end": "",
452
- "industry": meta.industry, "sector": meta.sector,
453
- "business_description": meta.description,
454
- "report_date": datetime.date.today().isoformat(),
455
- "analysis_period_years": len(fin_data.get("years", [])),
456
- "share_price": meta.share_price, "market_cap": meta.mkt_cap,
457
- }
458
- fin_data["models_used"] = engines
459
- fin_data.setdefault("sources", [])
460
-
461
- ticker_safe = meta.ticker.replace(".", "_").replace("/", "_")
462
- timestamp_suffix = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
463
-
464
- # Exporters path settings
465
- excel_bytes = None
466
- word_bytes = None
467
- pptx_bytes = None
468
-
469
- out_dir = PARENT_DIR / "outputs"
470
- os.makedirs(str(out_dir), exist_ok=True)
471
-
472
- # Build files in memory / disk
473
- if chk_excel:
474
- xlsx_path = out_dir / f"{ticker_safe}_financial_analysis_{timestamp_suffix}.xlsx"
475
- build_workbook(fin_data, str(xlsx_path))
476
- with open(xlsx_path, "rb") as f:
477
- excel_bytes = f.read()
478
- log_fn(f" βœ“ Created Excel analysis: {xlsx_path.name}")
479
-
480
- if chk_word:
481
- docx_path = out_dir / f"{ticker_safe}_financial_analysis_{timestamp_suffix}.docx"
482
- build_document(fin_data, str(docx_path))
483
- with open(docx_path, "rb") as f:
484
- word_bytes = f.read()
485
- log_fn(f" βœ“ Created Word summary: {docx_path.name}")
486
-
487
- if chk_pptx:
488
- pptx_path = out_dir / f"{ticker_safe}_financial_analysis_{timestamp_suffix}.pptx"
489
- build_presentation(fin_data, str(pptx_path))
490
- with open(pptx_path, "rb") as f:
491
- pptx_bytes = f.read()
492
- log_fn(f" βœ“ Created PowerPoint Presentation: {pptx_path.name}")
493
-
494
- log_fn(f"\n[7/7] βœ… Financial analysis process complete!")
495
- log_fn("="*60)
496
-
497
- # ── Generate download buttons in UI
498
- with download_container.container():
499
- st.success("Download ready!")
500
- dl_col1, dl_col2, dl_col3, dl_col4 = st.columns(4)
501
-
502
- download_files = []
503
-
504
- if excel_bytes:
505
- dl_col1.download_button(
506
- label="πŸ“₯ Download Excel",
507
- data=excel_bytes,
508
- file_name=f"{ticker_safe}_analysis.xlsx",
509
- mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
510
- use_container_width=True
511
- )
512
- download_files.append((f"{ticker_safe}_analysis.xlsx", excel_bytes))
513
-
514
- if word_bytes:
515
- dl_col2.download_button(
516
- label="πŸ“„ Download Word",
517
- data=word_bytes,
518
- file_name=f"{ticker_safe}_report.docx",
519
- mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
520
- use_container_width=True
521
- )
522
- download_files.append((f"{ticker_safe}_report.docx", word_bytes))
523
-
524
- if pptx_bytes:
525
- dl_col3.download_button(
526
- label="πŸ“Š Download PPTX",
527
- data=pptx_bytes,
528
- file_name=f"{ticker_safe}_deck.pptx",
529
- mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
530
- use_container_width=True
531
- )
532
- download_files.append((f"{ticker_safe}_deck.pptx", pptx_bytes))
533
-
534
- if len(download_files) > 1:
535
- import zipfile
536
- import io
537
- zip_buffer = io.BytesIO()
538
- with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
539
- for file_name, data in download_files:
540
- zip_file.writestr(file_name, data)
541
- dl_col4.download_button(
542
- label="πŸ“¦ Download All (ZIP)",
543
- data=zip_buffer.getvalue(),
544
- file_name=f"{ticker_safe}_all_reports.zip",
545
- mime="application/zip",
546
- use_container_width=True
547
- )
548
- # Write search activity log to audit table
549
- selected_formats = [f for f, ok in [("xlsx", chk_excel), ("docx", chk_word), ("pptx", chk_pptx)] if ok]
550
- save_audit_log(
551
- user_email=username,
552
- company_ticker=meta.ticker,
553
- company_name=meta.name,
554
- selected_formats=selected_formats,
555
- ai_models=engines,
556
- status="completed"
557
- )
558
-
559
- except Exception as e:
560
- log_fn(f"\n❌ Pipeline failed: {e}")
561
- import traceback
562
- log_fn(traceback.format_exc())
563
- st.error(f"Analysis failed: {e}")
564
-
565
- # Save audit log failure
566
- try:
567
- selected_formats = [f for f, ok in [("xlsx", chk_excel), ("docx", chk_word), ("pptx", chk_pptx)] if ok]
568
- save_audit_log(
569
- user_email=username,
570
- company_ticker=ticker_input,
571
- company_name="N/A",
572
- selected_formats=selected_formats,
573
- ai_models=engines,
574
- status="failed"
575
- )
576
- except:
577
- pass
578
-
579
- # ── Audit History Panel Table ───────────────────────────────────────────
580
- st.markdown("---")
581
- st.markdown("<h3 style='color:#60A5FA;margin-top:0;'>πŸ“œ Query Audit Trail & History</h3>", unsafe_allow_html=True)
582
- st.markdown("<p style='font-size:0.85rem;color:#9CA3AF;'>Past analysis runs requested by team members.</p>", unsafe_allow_html=True)
583
-
584
- history_logs = get_audit_logs()
585
- if history_logs:
586
- st.table(history_logs)
587
- else:
588
- st.info("No queries have been run yet.")
 
1
+ """
2
+ web_app/app.py β€” Cloud-hosted Streamlit Portal for Financial Analysis.
3
+
4
+ Provides a rich dark UI dashboard to scrape, compute, analyze and download reports
5
+ for Excel, Word, and PowerPoint formats using user-supplied LLM credentials.
6
+ """
7
+ import os
8
+ import sys
9
+ import time
10
+ import datetime
11
+ from pathlib import Path
12
+ import streamlit as st
13
+
14
+ # Setup sys.path so local packages can be resolved from parent directory
15
+ PARENT_DIR = Path(__file__).parent.parent
16
+ sys.path.insert(0, str(PARENT_DIR))
17
+
18
+ # Import builders and pipeline
19
+ from pipeline.resolver import resolve
20
+ from pipeline.fetcher import fetch_all
21
+ from pipeline.peer_selector import select_peers
22
+ from pipeline.calculator import calculate
23
+ from pipeline.analyst import generate_narrative
24
+ from builders.build_excel import build_workbook
25
+ from builders.build_docx import build_document
26
+ from builders.build_pptx import build_presentation
27
+ from web_app.database import save_audit_log, get_audit_logs
28
+
29
+ # Streamlit Page Config
30
+ st.set_page_config(
31
+ page_title="Financial Statement Analyzer Cloud Portal",
32
+ page_icon="πŸ“ˆ",
33
+ layout="wide",
34
+ initial_sidebar_state="expanded"
35
+ )
36
+
37
+ # Dark Theme Glassmorphism Custom CSS Injection
38
+ st.markdown("""
39
+ <style>
40
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap');
41
+
42
+ html, body, [class*="css"] {
43
+ font-family: 'Inter', sans-serif;
44
+ }
45
+
46
+ .main {
47
+ background-color: #0A0F1D;
48
+ color: #F3F4F6;
49
+ }
50
+
51
+ /* Sidebar styling */
52
+ [data-testid="stSidebar"] {
53
+ background-color: #0F162A;
54
+ border-right: 1px solid #1E293B;
55
+ }
56
+
57
+ /* Title and Subtitle */
58
+ .dashboard-title {
59
+ font-size: 2.5rem;
60
+ font-weight: 700;
61
+ background: linear-gradient(135deg, #60A5FA, #34D399);
62
+ -webkit-background-clip: text;
63
+ -webkit-text-fill-color: transparent;
64
+ margin-bottom: 0.2rem;
65
+ }
66
+
67
+ .dashboard-subtitle {
68
+ font-size: 1.1rem;
69
+ font-weight: 300;
70
+ color: #9CA3AF;
71
+ margin-bottom: 2rem;
72
+ }
73
+
74
+ /* Glowing card containers */
75
+ .glass-card {
76
+ background: rgba(30, 41, 59, 0.45);
77
+ border: 1px solid rgba(255, 255, 255, 0.08);
78
+ border-radius: 16px;
79
+ padding: 1.5rem;
80
+ backdrop-filter: blur(12px);
81
+ margin-bottom: 1.5rem;
82
+ box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
83
+ }
84
+
85
+ .status-log {
86
+ background-color: #0D1117 !important;
87
+ border: 1px solid #1F242C !important;
88
+ border-radius: 8px !important;
89
+ color: #C9D1D9 !important;
90
+ font-family: 'Consolas', monospace !important;
91
+ padding: 12px;
92
+ max-height: 380px;
93
+ overflow-y: auto;
94
+ }
95
+
96
+ /* Primary buttons styling */
97
+ div.stButton > button:first-child {
98
+ background: linear-gradient(135deg, #3B82F6, #10B981);
99
+ border: none;
100
+ color: white;
101
+ font-weight: 600;
102
+ padding: 0.6rem 2rem;
103
+ border-radius: 8px;
104
+ transition: transform 0.2s, box-shadow 0.2s;
105
+ box-shadow: 0 0 15px rgba(59, 130, 246, 0.4);
106
+ }
107
+
108
+ div.stButton > button:first-child:hover {
109
+ transform: translateY(-2px);
110
+ box-shadow: 0 0 25px rgba(16, 185, 129, 0.6);
111
+ color: white;
112
+ }
113
+
114
+ /* Checkbox layouts styling */
115
+ .stCheckbox > label {
116
+ color: #D1D5DB !important;
117
+ }
118
+ </style>
119
+ """, unsafe_allow_html=True)
120
+
121
+ # ── Sidebar β€” Connect Your AI ──────────────────────────────────────────
122
+ st.sidebar.markdown("""
123
+ <h2 style='color:#60A5FA;margin-top:0;'>πŸ€– Connect Your AI</h2>
124
+ <p style='font-size:0.8rem;color:#9CA3AF;'>
125
+ Bring your own key. Keys live only in your browser session β€” never stored on the server.
126
+ </p>
127
+ """, unsafe_allow_html=True)
128
+
129
+ # ── FREE OPTIONS ───────────────────────────────────────────────────────
130
+ st.sidebar.markdown("""
131
+ <div style='background:rgba(16,185,129,0.08);border:1px solid rgba(16,185,129,0.25);
132
+ border-radius:8px;padding:0.6rem 0.85rem;margin-bottom:0.5rem;'>
133
+ <span style='font-size:0.75rem;font-weight:700;color:#34D399;letter-spacing:0.06em;'>πŸ†“ FREE β€” NO CREDIT CARD</span>
134
+ </div>
135
+ """, unsafe_allow_html=True)
136
+
137
+ st.sidebar.markdown("""
138
+ **NVIDIA NIM** *(Llama 3.1 405B / Nemotron)*
139
+ <a href='https://build.nvidia.com/explore/discover' target='_blank' style='font-size:0.75rem;color:#34D399;'>Get free key β†’ build.nvidia.com</a>
140
+ """, unsafe_allow_html=True)
141
+ nvidia_key = st.sidebar.text_input(
142
+ "NVIDIA API Key", type="password",
143
+ placeholder="nvapi-…",
144
+ value=os.environ.get("NVIDIA_API_KEY", ""),
145
+ help="Free credits available. Sign up at build.nvidia.com.",
146
+ key="inp_nvidia"
147
+ )
148
+
149
+ st.sidebar.markdown("""
150
+ **Groq** *(Llama 3.3 70B β€” fast & free)*
151
+ <a href='https://console.groq.com/keys' target='_blank' style='font-size:0.75rem;color:#34D399;'>Get free key β†’ console.groq.com</a>
152
+ """, unsafe_allow_html=True)
153
+ groq_key = st.sidebar.text_input(
154
+ "Groq API Key", type="password",
155
+ placeholder="gsk_…",
156
+ value=os.environ.get("GROQ_API_KEY", ""),
157
+ help="100% free. Sign up at console.groq.com β†’ API Keys. No credit card.",
158
+ key="inp_groq"
159
+ )
160
+
161
+ st.sidebar.markdown("""
162
+ **Google Gemini** *(Gemini 2.0 Flash β€” free tier)*
163
+ <a href='https://aistudio.google.com/app/apikey' target='_blank' style='font-size:0.75rem;color:#34D399;'>Get free key β†’ aistudio.google.com</a>
164
+ """, unsafe_allow_html=True)
165
+ gemini_key = st.sidebar.text_input(
166
+ "Gemini API Key", type="password",
167
+ placeholder="AIza…",
168
+ value=os.environ.get("GEMINI_API_KEY", ""),
169
+ help="Free tier: 15 requests/min, 1M tokens/day. No billing needed.",
170
+ key="inp_gemini"
171
+ )
172
+
173
+ st.sidebar.markdown("""
174
+ **Hugging Face** *(Serverless API)*
175
+ <a href='https://huggingface.co/settings/tokens' target='_blank' style='font-size:0.75rem;color:#34D399;'>Get free key β†’ huggingface.co</a>
176
+ """, unsafe_allow_html=True)
177
+
178
+ _env_hf = os.environ.get("HF_API_KEY", "")
179
+ if _env_hf:
180
+ hf_key = _env_hf
181
+ st.sidebar.success("βœ… HF Key securely loaded from server secrets.")
182
+ else:
183
+ hf_key = st.sidebar.text_input(
184
+ "Hugging Face API Key", type="password",
185
+ placeholder="hf_…",
186
+ help="Used for intelligent peer selection and DeepSeek analysis.",
187
+ key="inp_hf"
188
+ )
189
+
190
+ # ── PAID OPTIONS ───────────────────────────────────────────────────────
191
+ st.sidebar.markdown("""
192
+ <div style='background:rgba(99,102,241,0.08);border:1px solid rgba(99,102,241,0.25);
193
+ border-radius:8px;padding:0.6rem 0.85rem;margin:0.5rem 0;'>
194
+ <span style='font-size:0.75rem;font-weight:700;color:#818CF8;letter-spacing:0.06em;'>πŸ’³ OPTIONAL β€” PAID APIS</span>
195
+ </div>
196
+ """, unsafe_allow_html=True)
197
+
198
+ with st.sidebar.expander("Claude (Anthropic) β€” paid", expanded=False):
199
+ st.markdown("<a href='https://console.anthropic.com/' target='_blank' style='font-size:0.75rem;color:#818CF8;'>Get key β†’ console.anthropic.com</a>", unsafe_allow_html=True)
200
+ claude_key = st.text_input(
201
+ "Claude API Key", type="password",
202
+ placeholder="sk-ant-…",
203
+ value=os.environ.get("CLAUDE_API_KEY", ""),
204
+ key="inp_claude"
205
+ )
206
+
207
+ with st.sidebar.expander("ChatGPT (OpenAI) β€” paid", expanded=False):
208
+ st.markdown("<a href='https://platform.openai.com/api-keys' target='_blank' style='font-size:0.75rem;color:#818CF8;'>Get key β†’ platform.openai.com</a>", unsafe_allow_html=True)
209
+ openai_key = st.text_input(
210
+ "OpenAI API Key", type="password",
211
+ placeholder="sk-proj-…",
212
+ value=os.environ.get("OPENAI_API_KEY", ""),
213
+ key="inp_openai"
214
+ )
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)
222
+ st.markdown("<p class='dashboard-subtitle'>Multi-model AI insights scraper and report compilation portal</p>", unsafe_allow_html=True)
223
+
224
+ # Left Column: Inputs
225
+ # Right Column: Exporters & Progress
226
+ col1, col2 = st.columns([1, 1.2])
227
+
228
+ with col1:
229
+ st.markdown("<div class='glass-card'>", unsafe_allow_html=True)
230
+ st.subheader("Analysis Parameters")
231
+
232
+ # ── Ticker Search & Suggestion Logic ──
233
+ search_col1, search_col2 = st.columns([3, 1])
234
+ with search_col1:
235
+ search_query = st.text_input("Search Company Name or Ticker", placeholder="e.g. TCS, Reliance, AAPL, MSFT")
236
+ with search_col2:
237
+ st.markdown("<div style='margin-top:28px;'></div>", unsafe_allow_html=True)
238
+ search_clicked = st.button("πŸ” Find", use_container_width=True)
239
+
240
+ ticker_input = search_query # default if no suggestions
241
+
242
+ if search_clicked and search_query:
243
+ with st.spinner("Searching..."):
244
+ try:
245
+ import yahooquery as yf
246
+ res = yf.Search(search_query, max_results=8)
247
+ quotes = [q for q in res.quotes if q.get("quoteType") in ("EQUITY", "MUTUALFUND", "ETF")]
248
+ st.session_state["ticker_suggestions"] = quotes
249
+ except Exception:
250
+ st.session_state["ticker_suggestions"] = []
251
+
252
+ if "ticker_suggestions" in st.session_state and st.session_state["ticker_suggestions"]:
253
+ options = {}
254
+ for q in st.session_state["ticker_suggestions"]:
255
+ name = q.get('longname') or q.get('shortname') or q.get('symbol')
256
+ label = f"{name} ({q['symbol']}) - {q.get('exchange', 'Unknown')}"
257
+ options[label] = q['symbol']
258
+
259
+ options[f"Use exact input: '{search_query}'"] = search_query
260
+ selected_label = st.selectbox("Confirm Company:", list(options.keys()))
261
+ ticker_input = options[selected_label]
262
+
263
+ col_years, col_refresh = st.columns([1, 1])
264
+ with col_years:
265
+ analysis_years = st.selectbox("Analysis Horizon", options=[5, 7, 10], index=0)
266
+ with col_refresh:
267
+ force_refresh = st.checkbox("Bypass Cache (Force Scrape)", value=False)
268
+
269
+ st.markdown("<b>Download Formats:</b>", unsafe_allow_html=True)
270
+ chk_excel = st.checkbox("Excel Analysis Workbook (.xlsx)", value=True)
271
+ chk_word = st.checkbox("Word Summary Report (.docx)", value=True)
272
+ chk_pptx = st.checkbox("PowerPoint Pitch Deck (.pptx)", value=True)
273
+
274
+ # ── AI Engine selection β€” auto-disable if key not supplied
275
+ st.markdown("<b>AI Engines:</b>", unsafe_allow_html=True)
276
+
277
+ _hint = "<span style='font-size:0.72rem;color:#9CA3AF;'>(add key in sidebar β†’)</span>"
278
+
279
+ engines = []
280
+
281
+ if st.checkbox(
282
+ "πŸ†“ NVIDIA NIM β€” Llama 3.1 405B",
283
+ value=bool(nvidia_key),
284
+ disabled=not nvidia_key,
285
+ help="Free credits. Get key at build.nvidia.com"
286
+ ):
287
+ engines.append("nvidia")
288
+
289
+ if st.checkbox(
290
+ "πŸ†“ Groq β€” Llama 3.3 70B",
291
+ value=bool(groq_key),
292
+ disabled=not groq_key,
293
+ help="Free. Get key at console.groq.com"
294
+ ):
295
+ engines.append("groq")
296
+
297
+ if st.checkbox(
298
+ "πŸ†“ Hugging Face β€” DeepSeek-R1",
299
+ value=bool(hf_key),
300
+ disabled=not hf_key,
301
+ help="Free via Hugging Face Inference API"
302
+ ):
303
+ engines.append("huggingface")
304
+
305
+ if st.checkbox(
306
+ "πŸ†“ Gemini 2.0 Flash (Google)",
307
+ value=bool(gemini_key),
308
+ disabled=not gemini_key,
309
+ help="Free tier. Get key at aistudio.google.com"
310
+ ):
311
+ engines.append("gemini")
312
+
313
+ if st.checkbox(
314
+ "πŸ’³ Claude Haiku (Anthropic)",
315
+ value=False,
316
+ disabled=not claude_key,
317
+ help="Paid. Add key in sidebar to enable."
318
+ ):
319
+ engines.append("claude")
320
+
321
+ if st.checkbox(
322
+ "πŸ’³ ChatGPT β€” GPT-4o mini (OpenAI)",
323
+ value=False,
324
+ disabled=not openai_key,
325
+ help="Paid. Add key in sidebar to enable."
326
+ ):
327
+ engines.append("openai")
328
+
329
+ if not groq_key and not gemini_key:
330
+ st.info("πŸ’‘ Add a **free Groq key** in the sidebar to enable AI narratives.", icon="πŸ”‘")
331
+
332
+ st.markdown("<br>", unsafe_allow_html=True)
333
+ start_btn = st.button("Begin Financial Scrape & Analysis")
334
+ st.markdown("</div>", unsafe_allow_html=True)
335
+
336
+ with col2:
337
+ st.markdown("<div class='glass-card'>", unsafe_allow_html=True)
338
+ st.subheader("Execution Status & Logs")
339
+
340
+ log_container = st.empty()
341
+ download_container = st.empty()
342
+
343
+ log_container.code("Ready to analyze. Input a company and click the button above.", language="text")
344
+ st.markdown("</div>", 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.")
354
+ else:
355
+ # Inject credentials into all modules that cache them at import time
356
+ import config
357
+ import pipeline.analyst
358
+ import pipeline.key_validator
359
+
360
+ config.GEMINI_API_KEY = gemini_key
361
+ config.CLAUDE_API_KEY = claude_key
362
+ config.OPENAI_API_KEY = openai_key
363
+ config.GROQ_API_KEY = groq_key
364
+ config.NVIDIA_API_KEY = nvidia_key
365
+ config.HF_API_KEY = hf_key
366
+
367
+ os.environ["GEMINI_API_KEY"] = gemini_key
368
+ os.environ["CLAUDE_API_KEY"] = claude_key
369
+ os.environ["OPENAI_API_KEY"] = openai_key
370
+ os.environ["NVIDIA_API_KEY"] = nvidia_key
371
+ os.environ["HF_API_KEY"] = hf_key
372
+
373
+ pipeline.analyst.GEMINI_API_KEY = gemini_key
374
+ pipeline.analyst.CLAUDE_API_KEY = claude_key
375
+ pipeline.analyst.OPENAI_API_KEY = openai_key
376
+ pipeline.analyst.GROQ_API_KEY = groq_key
377
+ pipeline.analyst.NVIDIA_API_KEY = nvidia_key
378
+
379
+ pipeline.key_validator.GEMINI_API_KEY = gemini_key
380
+ pipeline.key_validator.CLAUDE_API_KEY = claude_key
381
+ pipeline.key_validator.OPENAI_API_KEY = openai_key
382
+ pipeline.key_validator.GROQ_API_KEY = groq_key
383
+ pipeline.key_validator.NVIDIA_API_KEY = nvidia_key
384
+
385
+ # Push into environment for any sub-process
386
+ for k, v in [("GEMINI_API_KEY", gemini_key), ("CLAUDE_API_KEY", claude_key),
387
+ ("OPENAI_API_KEY", openai_key), ("GROQ_API_KEY", groq_key),
388
+ ("NVIDIA_API_KEY", nvidia_key), ("HF_API_KEY", hf_key)]:
389
+ if v: os.environ[k] = v
390
+
391
+ # Validate that selected engines have keys
392
+ failed_validation = False
393
+ engine_key_map = {
394
+ "nvidia": (nvidia_key, "NVIDIA"),
395
+ "groq": (groq_key, "Groq"),
396
+ "gemini": (gemini_key, "Gemini"),
397
+ "claude": (claude_key, "Claude"),
398
+ "openai": (openai_key, "ChatGPT"),
399
+ "huggingface": (hf_key, "Hugging Face"),
400
+ }
401
+ for engine in engines:
402
+ key, label = engine_key_map[engine]
403
+ if not key:
404
+ st.error(f"Please supply a {label} API Key or uncheck the {label} model.")
405
+ failed_validation = True
406
+
407
+ if not failed_validation:
408
+ logs = []
409
+
410
+ def log_fn(msg):
411
+ logs.append(msg)
412
+ log_container.code("\n".join(logs), language="text")
413
+
414
+ try:
415
+ log_fn("="*60)
416
+ log_fn(f"Starting analysis for: '{ticker_input}'")
417
+ log_fn(f"Time: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
418
+ log_fn(f"Formats: " + ", ".join([f for f, ok in [("Excel", chk_excel), ("Word", chk_word), ("PowerPoint", chk_pptx)] if ok]))
419
+ log_fn(f"AI Models: {engines or ['None (Data Only)']}")
420
+ log_fn("="*60)
421
+
422
+ # 1. Resolve Company Info
423
+ log_fn("\n[1/7] Resolving company metadata …")
424
+ meta = resolve(ticker_input, log_fn=log_fn)
425
+ log_fn(f" βœ“ {meta.name} | {meta.ticker} | {meta.exchange}")
426
+
427
+ # 2. Fetch Statements
428
+ log_fn("\n[2/7] Scrape financial statements …")
429
+ fin_data = fetch_all(meta, force_refresh=force_refresh, log_fn=log_fn)
430
+
431
+ # 3. Peers Comparison Selection
432
+ log_fn("\n[3/7] Benchmarking peer selection …")
433
+ fin_data["peers"] = select_peers(meta, log_fn=log_fn)
434
+
435
+ # 4. Math ratios calculations
436
+ log_fn("\n[4/7] Computing financial ratios & DuPont decomposition …")
437
+ fin_data = calculate(fin_data, meta.to_dict())
438
+ log_fn(" βœ“ Completed ratio math computations")
439
+
440
+ # 5. AI Synthesis Narrative
441
+ log_fn("\n[5/7] Requesting AI Model narrative generation …")
442
+ narrative = generate_narrative(fin_data, meta.to_dict(), engines, log_fn=log_fn)
443
+ fin_data.update(narrative)
444
+
445
+ # 6. Generate outputs
446
+ log_fn("\n[6/7] Building output report files …")
447
+ fin_data["company"] = {
448
+ "name": meta.name, "ticker": meta.ticker, "exchange": meta.exchange,
449
+ "currency": fin_data.get("currency", meta.currency),
450
+ "units": fin_data.get("units", "millions"),
451
+ "fiscal_year_end": "",
452
+ "industry": meta.industry, "sector": meta.sector,
453
+ "business_description": meta.description,
454
+ "report_date": datetime.date.today().isoformat(),
455
+ "analysis_period_years": len(fin_data.get("years", [])),
456
+ "share_price": meta.share_price, "market_cap": meta.mkt_cap,
457
+ }
458
+ fin_data["models_used"] = engines
459
+ fin_data.setdefault("sources", [])
460
+
461
+ ticker_safe = meta.ticker.replace(".", "_").replace("/", "_")
462
+ timestamp_suffix = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
463
+
464
+ # Exporters path settings
465
+ excel_bytes = None
466
+ word_bytes = None
467
+ pptx_bytes = None
468
+
469
+ out_dir = PARENT_DIR / "outputs"
470
+ os.makedirs(str(out_dir), exist_ok=True)
471
+
472
+ # Build files in memory / disk
473
+ if chk_excel:
474
+ xlsx_path = out_dir / f"{ticker_safe}_financial_analysis_{timestamp_suffix}.xlsx"
475
+ build_workbook(fin_data, str(xlsx_path))
476
+ with open(xlsx_path, "rb") as f:
477
+ excel_bytes = f.read()
478
+ log_fn(f" βœ“ Created Excel analysis: {xlsx_path.name}")
479
+
480
+ if chk_word:
481
+ docx_path = out_dir / f"{ticker_safe}_financial_analysis_{timestamp_suffix}.docx"
482
+ build_document(fin_data, str(docx_path))
483
+ with open(docx_path, "rb") as f:
484
+ word_bytes = f.read()
485
+ log_fn(f" βœ“ Created Word summary: {docx_path.name}")
486
+
487
+ if chk_pptx:
488
+ pptx_path = out_dir / f"{ticker_safe}_financial_analysis_{timestamp_suffix}.pptx"
489
+ build_presentation(fin_data, str(pptx_path))
490
+ with open(pptx_path, "rb") as f:
491
+ pptx_bytes = f.read()
492
+ log_fn(f" βœ“ Created PowerPoint Presentation: {pptx_path.name}")
493
+
494
+ log_fn(f"\n[7/7] βœ… Financial analysis process complete!")
495
+ log_fn("="*60)
496
+
497
+ # ── Generate download buttons in UI
498
+ with download_container.container():
499
+ st.success("Download ready!")
500
+ dl_col1, dl_col2, dl_col3, dl_col4 = st.columns(4)
501
+
502
+ download_files = []
503
+
504
+ if excel_bytes:
505
+ dl_col1.download_button(
506
+ label="πŸ“₯ Download Excel",
507
+ data=excel_bytes,
508
+ file_name=f"{ticker_safe}_analysis.xlsx",
509
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
510
+ use_container_width=True
511
+ )
512
+ download_files.append((f"{ticker_safe}_analysis.xlsx", excel_bytes))
513
+
514
+ if word_bytes:
515
+ dl_col2.download_button(
516
+ label="πŸ“„ Download Word",
517
+ data=word_bytes,
518
+ file_name=f"{ticker_safe}_report.docx",
519
+ mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
520
+ use_container_width=True
521
+ )
522
+ download_files.append((f"{ticker_safe}_report.docx", word_bytes))
523
+
524
+ if pptx_bytes:
525
+ dl_col3.download_button(
526
+ label="πŸ“Š Download PPTX",
527
+ data=pptx_bytes,
528
+ file_name=f"{ticker_safe}_deck.pptx",
529
+ mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
530
+ use_container_width=True
531
+ )
532
+ download_files.append((f"{ticker_safe}_deck.pptx", pptx_bytes))
533
+
534
+ if len(download_files) > 1:
535
+ import zipfile
536
+ import io
537
+ zip_buffer = io.BytesIO()
538
+ with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
539
+ for file_name, data in download_files:
540
+ zip_file.writestr(file_name, data)
541
+ dl_col4.download_button(
542
+ label="πŸ“¦ Download All (ZIP)",
543
+ data=zip_buffer.getvalue(),
544
+ file_name=f"{ticker_safe}_all_reports.zip",
545
+ mime="application/zip",
546
+ use_container_width=True
547
+ )
548
+ # Write search activity log to audit table
549
+ selected_formats = [f for f, ok in [("xlsx", chk_excel), ("docx", chk_word), ("pptx", chk_pptx)] if ok]
550
+ save_audit_log(
551
+ user_email=username,
552
+ company_ticker=meta.ticker,
553
+ company_name=meta.name,
554
+ selected_formats=selected_formats,
555
+ ai_models=engines,
556
+ status="completed"
557
+ )
558
+
559
+ except Exception as e:
560
+ log_fn(f"\n❌ Pipeline failed: {e}")
561
+ import traceback
562
+ log_fn(traceback.format_exc())
563
+ st.error(f"Analysis failed: {e}")
564
+
565
+ # Save audit log failure
566
+ try:
567
+ selected_formats = [f for f, ok in [("xlsx", chk_excel), ("docx", chk_word), ("pptx", chk_pptx)] if ok]
568
+ save_audit_log(
569
+ user_email=username,
570
+ company_ticker=ticker_input,
571
+ company_name="N/A",
572
+ selected_formats=selected_formats,
573
+ ai_models=engines,
574
+ status="failed"
575
+ )
576
+ except:
577
+ pass
578
+
579
+ # ── Audit History Panel Table ───────────────────────────────────────────
580
+ st.markdown("---")
581
+ st.markdown("<h3 style='color:#60A5FA;margin-top:0;'>πŸ“œ Query Audit Trail & History</h3>", unsafe_allow_html=True)
582
+ st.markdown("<p style='font-size:0.85rem;color:#9CA3AF;'>Past analysis runs requested by team members.</p>", unsafe_allow_html=True)
583
+
584
+ history_logs = get_audit_logs()
585
+ if history_logs:
586
+ st.table(history_logs)
587
+ else:
588
+ st.info("No queries have been run yet.")