Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- builders/build_docx.py +410 -410
- builders/build_excel.py +587 -587
- pipeline/resolver.py +354 -346
- 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 (
|
| 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 (
|
| 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.
|
| 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 |
-
# ββ
|
| 167 |
-
|
| 168 |
-
def
|
| 169 |
-
"""Try to resolve a ticker via
|
| 170 |
-
def log(msg):
|
| 171 |
-
if log_fn: log_fn(msg)
|
| 172 |
-
try:
|
| 173 |
-
import
|
| 174 |
-
t =
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
"
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
return
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
if
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 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
|
| 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.")
|