Shivam4096 commited on
Commit
558d330
·
verified ·
1 Parent(s): 800a6d1

Upload folder using huggingface_hub

Browse files
.gitignore ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ *.egg-info/
7
+ .eggs/
8
+
9
+ # Virtual environments
10
+ .venv/
11
+ venv/
12
+ env/
13
+
14
+ # App outputs (generated files — not needed in the image)
15
+ outputs/
16
+ test_outputs/
17
+ *.xlsx
18
+ *.docx
19
+ *.pptx
20
+
21
+ # Secrets & env
22
+ .env
23
+
24
+ # GUI (desktop-only, not needed for web deployment)
25
+ gui/
26
+ build/
27
+ dist/
28
+ *.spec
29
+ build_exe.py
30
+
31
+ # Database (ephemeral, let the container create it fresh)
32
+ storage/history.db
33
+ web_app/financial_audit.db
34
+
35
+ # IDE
36
+ .vscode/
37
+ .idea/
38
+ *.swp
Dockerfile ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Base ───────────────────────────────────────────────────────────────────────
2
+ FROM python:3.11-slim
3
+
4
+ # ── System dependencies ────────────────────────────────────────────────────────
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ gcc \
7
+ libxml2-dev \
8
+ libxslt-dev \
9
+ zlib1g-dev \
10
+ curl \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # ── App directory ──────────────────────────────────────────────────────────────
14
+ WORKDIR /app
15
+
16
+ # ── Install Python dependencies ────────────────────────────────────────────────
17
+ COPY web_app/requirements.txt ./requirements.txt
18
+ RUN pip install --no-cache-dir -r requirements.txt
19
+
20
+ # ── Copy full application source ───────────────────────────────────────────────
21
+ COPY . .
22
+
23
+ # ── Create writable directories ────────────────────────────────────────────────
24
+ RUN mkdir -p outputs storage web_app/__pycache__ \
25
+ && chmod -R 777 /app
26
+
27
+ # ── Streamlit config — disable telemetry, set port ────────────────────────────
28
+ RUN mkdir -p /root/.streamlit
29
+ COPY web_app/streamlit_config.toml /root/.streamlit/config.toml
30
+
31
+ # ── HF Spaces: port 7860 ──────────────────────────────────────────────────────
32
+ EXPOSE 7860
33
+
34
+ # ── Launch Streamlit ───────────────────────────────────────────────────────────
35
+ CMD ["streamlit", "run", "web_app/app.py", \
36
+ "--server.port=7860", \
37
+ "--server.address=0.0.0.0", \
38
+ "--server.headless=true", \
39
+ "--browser.gatherUsageStats=false"]
README.md CHANGED
@@ -1,10 +1,64 @@
1
  ---
2
- title: Financial2API
3
- emoji: 🌍
4
- colorFrom: yellow
5
- colorTo: purple
6
  sdk: docker
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Financial Statement Analyzer
3
+ emoji: 📈
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
  pinned: false
8
+ app_port: 7860
9
+ short_description: AI financial analysis portal with free BYOK models.
10
  ---
11
 
12
+ # 📈 Financial Statement Analyzer
13
+
14
+ Cloud-hosted AI analysis portal. Fetches financial statements for any listed company (Indian NSE/BSE or global NYSE/NASDAQ), computes ratios, generates AI narratives, and produces downloadable Excel, Word, and PowerPoint reports.
15
+
16
+ **Bring Your Own Key (BYOK)** — keys stay in your browser session only, never stored.
17
+
18
+ ## 🆓 Free AI Options (No Credit Card Needed)
19
+
20
+ | Provider | Model | Where to get key |
21
+ |---|---|---|
22
+ | **Groq** *(recommended)* | Llama 3.3 70B | [console.groq.com](https://console.groq.com/keys) — instant signup |
23
+ | **Google Gemini** | Gemini 2.0 Flash | [aistudio.google.com](https://aistudio.google.com/app/apikey) — free tier |
24
+
25
+ ## 💳 Optional Paid AIs
26
+
27
+ | Provider | Model | Where to get key |
28
+ |---|---|---|
29
+ | Anthropic Claude | Claude Haiku | [console.anthropic.com](https://console.anthropic.com/) |
30
+ | OpenAI | GPT-4o mini | [platform.openai.com](https://platform.openai.com/api-keys) |
31
+
32
+ ## How to Use
33
+
34
+ 1. Open the app → paste a **free Groq or Gemini key** in the sidebar
35
+ 2. Enter a company name/ticker (e.g. TCS, Reliance, AAPL)
36
+ 3. Pick formats (Excel / Word / PowerPoint) and click **Analyze**
37
+ 4. Download your reports instantly
38
+
39
+ ## Features
40
+
41
+ - 🔍 **Smart Ticker Resolution** — resolves company names via yfinance + FMP
42
+ - 📊 **Financial Data** — Income statement, balance sheet, cash flow, 5–10 year history
43
+ - 🧮 **Ratio Engine** — 30+ ratios + DuPont decomposition
44
+ - 🤖 **Multi-Model AI** — Use multiple providers simultaneously; outputs combined with attribution
45
+ - 📥 **Export Reports** — Excel workbook, Word summary, PowerPoint pitch deck
46
+ - 📜 **Audit Trail** — Every run logged with user, ticker, formats, and AI models used
47
+
48
+ ## Optional: Persistent Audit Logs (Supabase)
49
+
50
+ Without Supabase, logs reset on redeploy. To persist them:
51
+
52
+ | Secret | Value |
53
+ |---|---|
54
+ | `SUPABASE_URL` | Your Supabase project URL |
55
+ | `SUPABASE_KEY` | Supabase anon public key |
56
+
57
+ ## Tech Stack
58
+
59
+ - **Frontend**: Streamlit with custom dark glassmorphism CSS
60
+ - **Data**: yfinance (primary), FMP API (secondary), screener.in (Indian fallback)
61
+ - **AI**: Groq/Llama (free), Gemini (free tier), Claude, ChatGPT
62
+ - **Reports**: openpyxl (Excel), python-docx (Word), python-pptx (PowerPoint)
63
+ - **Audit**: SQLite (local) or Supabase (cloud persistent)
64
+ - **Container**: Docker on Python 3.11-slim
builders/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # builders package
builders/build_docx.py ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ builders/build_docx.py — Enhanced Word report builder.
4
+ Sections: Cover, Executive Summary, Company Profile, Industry Profile,
5
+ SWOT, Industry Trends, Financial Trends, Ratios, DuPont, Peer Comparison,
6
+ Key Insights, Risks, Things to Watch, Limitations, Sources.
7
+ """
8
+ import json, sys, datetime
9
+ from pathlib import Path
10
+ from typing import Dict, List, Optional
11
+
12
+ try:
13
+ from docx import Document
14
+ from docx.shared import Pt, Cm, RGBColor, Inches
15
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
16
+ from docx.enum.table import WD_ALIGN_VERTICAL, WD_TABLE_ALIGNMENT
17
+ from docx.oxml.ns import qn
18
+ from docx.oxml import OxmlElement
19
+ except ImportError:
20
+ print("ERROR: pip install python-docx", file=sys.stderr); sys.exit(1)
21
+
22
+ # ── Colors ────────────────────────────────────────────────────────────────────
23
+ NAVY = RGBColor(0x1F, 0x4E, 0x78)
24
+ GRAY = RGBColor(0x59, 0x59, 0x59)
25
+ GREEN_DARK = RGBColor(0x37, 0x5A, 0x3D)
26
+
27
+ # ── Formatting helpers ────────────────────────────────────────────────────────
28
+
29
+ def _shd(cell, hex_color):
30
+ tcp = cell._tc.get_or_add_tcPr()
31
+ s = OxmlElement("w:shd")
32
+ s.set(qn("w:val"), "clear"); s.set(qn("w:color"), "auto"); s.set(qn("w:fill"), hex_color)
33
+ tcp.append(s)
34
+
35
+ def _borders(cell, color="BFBFBF", sz="4"):
36
+ tcp = cell._tc.get_or_add_tcPr()
37
+ tb = OxmlElement("w:tcBorders")
38
+ for edge in ("top","left","bottom","right"):
39
+ b = OxmlElement(f"w:{edge}")
40
+ b.set(qn("w:val"),"single"); b.set(qn("w:sz"),sz); b.set(qn("w:color"),color)
41
+ tb.append(b)
42
+ tcp.append(tb)
43
+
44
+ def _hdr_repeat(row):
45
+ trp = row._tr.get_or_add_trPr()
46
+ th = OxmlElement("w:tblHeader"); th.set(qn("w:val"),"true"); trp.append(th)
47
+
48
+ def _add_heading(doc, text, level=1):
49
+ sizes = {0:28, 1:18, 2:14, 3:12}
50
+ p = doc.add_paragraph()
51
+ r = p.add_run(text)
52
+ r.font.size = Pt(sizes.get(level, 12)); r.font.bold = True
53
+ r.font.color.rgb = NAVY; r.font.name = "Calibri"
54
+ p.paragraph_format.space_before = Pt(12); p.paragraph_format.space_after = Pt(6)
55
+ return p
56
+
57
+ def _add_text(doc, text, size=11):
58
+ if not text: return
59
+ for block in [b.strip() for b in str(text).split("\n\n") if b.strip()]:
60
+ p = doc.add_paragraph()
61
+ r = p.add_run(block.replace("\n"," "))
62
+ r.font.size = Pt(size); r.font.name = "Calibri"
63
+ p.paragraph_format.space_after = Pt(8)
64
+
65
+ def _fmt_v(v, kind="money", currency="INR", units="Crores"):
66
+ if v is None: return "\u2014"
67
+ try:
68
+ sym = "\u20b9" if currency == "INR" else ("$" if currency == "USD" else "")
69
+ if kind == "money":
70
+ # Data is already scaled (Crores for INR, Millions for USD)
71
+ av = abs(v)
72
+ if currency == "INR":
73
+ if av >= 100000: return f"{sym}{v/100000:,.2f} L.Cr"
74
+ return f"{sym}{v:,.2f} Cr"
75
+ else:
76
+ if av >= 1000: return f"{sym}{v/1000:,.2f}B"
77
+ return f"{sym}{v:,.2f}M"
78
+ if kind == "money_dec": return f"{v:,.2f}"
79
+ if kind == "pct": return f"{v*100:.1f}%"
80
+ if kind in ("ratio", "days"): return f"{v:.2f}"
81
+ return str(v)
82
+ except: return str(v)
83
+
84
+ def _page_break(doc):
85
+ p = doc.add_paragraph(); p.add_run().add_break()
86
+
87
+ # ── Table builder ─────────────────────────────────────────────────────────────
88
+
89
+ def _table(doc, headers, rows, formats=None, currency="INR", units="Crores"):
90
+ t = doc.add_table(rows=1+len(rows), cols=len(headers))
91
+ t.alignment = WD_TABLE_ALIGNMENT.LEFT
92
+ hc = t.rows[0].cells
93
+ for i, h in enumerate(headers):
94
+ hc[i].text = ""
95
+ p = hc[i].paragraphs[0]
96
+ r = p.add_run(str(h)); r.font.bold = True; r.font.color.rgb = RGBColor(0xFF,0xFF,0xFF)
97
+ r.font.size = Pt(10); r.font.name = "Calibri"
98
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER if i > 0 else WD_ALIGN_PARAGRAPH.LEFT
99
+ _shd(hc[i], "1F4E78"); _borders(hc[i])
100
+ _hdr_repeat(t.rows[0])
101
+ for ri, row in enumerate(rows):
102
+ bg = "F2F2F2" if ri % 2 else "FFFFFF"
103
+ cells = t.rows[ri+1].cells
104
+ for ci, val in enumerate(row):
105
+ cells[ci].text = ""
106
+ p = cells[ci].paragraphs[0]
107
+ kind = formats[ci] if formats and ci < len(formats) else "money"
108
+ if ci == 0:
109
+ text = str(val) if val is not None else "—"
110
+ else:
111
+ text = _fmt_v(val, kind, currency, units)
112
+ r = p.add_run(text); r.font.size = Pt(10); r.font.name = "Calibri"
113
+ p.alignment = WD_ALIGN_PARAGRAPH.LEFT if ci == 0 else WD_ALIGN_PARAGRAPH.RIGHT
114
+ _shd(cells[ci], bg); _borders(cells[ci])
115
+ doc.add_paragraph().paragraph_format.space_after = Pt(6)
116
+ return t
117
+
118
+ def _gather(items, src, years):
119
+ return [[label] + [(src.get(key) or [None]*len(years))[:len(years)][i]
120
+ for i in range(len(years))] for label, key in items]
121
+
122
+ # ── Section builders ──────────────────────────────────────────────────────────
123
+
124
+ def _cover(doc, data):
125
+ co = data.get("company", {})
126
+ units_label = data.get("units_label", data.get("units", "Crores"))
127
+ currency = data.get("currency", co.get("currency", "INR"))
128
+ for _ in range(3): doc.add_paragraph()
129
+ p = doc.add_paragraph()
130
+ r = p.add_run("Financial Statement Analysis")
131
+ r.font.size = Pt(32); r.font.bold = True; r.font.color.rgb = NAVY; r.font.name = "Calibri"
132
+ p = doc.add_paragraph()
133
+ r = p.add_run(co.get("name",""))
134
+ r.font.size = Pt(24); r.font.color.rgb = GRAY; r.font.name = "Calibri"
135
+ p = doc.add_paragraph()
136
+ r = p.add_run(f"{co.get('ticker','')} \u00b7 {co.get('exchange','')} \u00b7 {co.get('industry','')}")
137
+ r.font.size = Pt(13); r.font.color.rgb = GRAY; r.font.italic = True; r.font.name = "Calibri"
138
+ for _ in range(6): doc.add_paragraph()
139
+ for text in [
140
+ f"Report date: {co.get('report_date', datetime.date.today().isoformat())}",
141
+ f"Analysis window: {', '.join(data.get('years',[]))}",
142
+ f"Reporting currency: {currency} | Units: {units_label}",
143
+ f"Market cap: {co.get('market_cap', 'N/A')} | Share price: {co.get('share_price', 'N/A')}",
144
+ f"AI models used: {', '.join(data.get('models_used',[]) or ['Data-only (no AI narrative)'])}",
145
+ f"Data source: {data.get('source', 'Yahoo Finance (yfinance)')}",
146
+ ]:
147
+ p = doc.add_paragraph(); r = p.add_run(text)
148
+ r.font.size = Pt(11); r.font.color.rgb = GRAY; r.font.name = "Calibri"
149
+ _page_break(doc)
150
+
151
+
152
+ def _swot_section(doc, data):
153
+ _add_heading(doc, "SWOT Analysis", 1)
154
+ swot = data.get("swot", {})
155
+ configs = [
156
+ ("Strengths", "strengths", "C6EFCE", GREEN_DARK),
157
+ ("Weaknesses", "weaknesses", "FFC7CE", RGBColor(0xC0,0x00,0x00)),
158
+ ("Opportunities", "opportunities", "DDEBF7", NAVY),
159
+ ("Threats", "threats", "FFEB9C", RGBColor(0x7F,0x60,0x00)),
160
+ ]
161
+ t = doc.add_table(rows=2, cols=2)
162
+ t.alignment = WD_TABLE_ALIGNMENT.LEFT
163
+ idx = 0
164
+ for row_i in range(2):
165
+ for col_i in range(2):
166
+ if idx >= len(configs): break
167
+ label, key, bg, color = configs[idx]; idx += 1
168
+ cell = t.cell(row_i, col_i)
169
+ cell.text = ""
170
+ p = cell.paragraphs[0]
171
+ r = p.add_run(label); r.font.bold = True; r.font.size = Pt(12)
172
+ r.font.color.rgb = color; r.font.name = "Calibri"
173
+ _shd(cell, bg)
174
+ for item in swot.get(key, []):
175
+ p2 = cell.add_paragraph(f"• {item}")
176
+ for run in p2.runs: run.font.size = Pt(10); run.font.name = "Calibri"
177
+ doc.add_paragraph()
178
+
179
+
180
+ def _trends_section(doc, data):
181
+ _add_heading(doc, "Multi-Year Financial Trends", 1)
182
+ years = data.get("years", [])
183
+ currency = data.get("currency", "INR")
184
+ units_label = data.get("units_label", data.get("units", "Crores"))
185
+ trend = data.get("trend_analysis", {})
186
+
187
+ for section_title, prose_key, items, src_key, fmts in [
188
+ ("Revenue & Profitability", "revenue_and_profitability",
189
+ [("Revenue","revenue"),("Gross Profit","gross_profit"),("Operating Income","operating_income"),
190
+ ("Net Income","net_income")],
191
+ "income_statement", ["text"]+["money"]*len(years)),
192
+ ("Balance Sheet Strength", "balance_sheet_strength",
193
+ [("Total Assets","total_assets"),("Total Equity","total_equity"),
194
+ ("Long-term Debt","long_term_debt"),("Current Ratio","current_ratio")],
195
+ "balance_sheet", ["text"]+["money"]*len(years)),
196
+ ("Cash Flow Generation", "cash_flow_generation",
197
+ [("Cash from Operations","cash_from_operations"),("Capex","capex"),("Free Cash Flow","free_cash_flow")],
198
+ "cash_flow", ["text"]+["money"]*len(years)),
199
+ ]:
200
+ _add_heading(doc, section_title, 2)
201
+ _add_text(doc, trend.get(prose_key,""))
202
+ src_data = data.get(src_key, {})
203
+ if src_key == "balance_sheet":
204
+ merged = dict(data.get("balance_sheet",{})); merged.update(data.get("ratios",{}))
205
+ src_data = merged
206
+ rows = _gather(items, src_data, years)
207
+ _table(doc, [f"({units_label})"]+years, rows, fmts, currency, units_label)
208
+
209
+
210
+ def _ratio_section(doc, data):
211
+ _add_heading(doc, "Financial Ratio Analysis", 1)
212
+ _add_text(doc, data.get("ratio_analysis_commentary",""))
213
+ years = data.get("years", [])
214
+ ratios = data.get("ratios", {})
215
+ currency = data.get("currency", "INR")
216
+ units_label = data.get("units_label", data.get("units", "Crores"))
217
+ for sec_name, items in [
218
+ ("Profitability", [("Gross Margin","gross_margin","pct"),("Operating Margin","operating_margin","pct"),
219
+ ("Net Margin","net_margin","pct"),("ROE","roe","pct"),("ROIC","roic","pct")]),
220
+ ("Liquidity & Solvency", [("Current Ratio","current_ratio","ratio"),("Quick Ratio","quick_ratio","ratio"),
221
+ ("Debt-to-Equity","debt_to_equity","ratio"),("Net Debt/EBITDA","net_debt_to_ebitda","ratio")]),
222
+ ("Efficiency", [("Asset Turnover","asset_turnover","ratio"),("DSO (days)","days_sales_outstanding","days"),
223
+ ("DPO (days)","days_payables","days"),("CCC (days)","cash_conversion_cycle","days")]),
224
+ ("Cash Flow Quality", [("OCF/Net Income","ocf_to_net_income","ratio"),("FCF Margin","fcf_margin","pct"),
225
+ ("FCF Conversion","fcf_conversion","ratio")]),
226
+ ]:
227
+ _add_heading(doc, sec_name, 3)
228
+ rows = []
229
+ for label, key, kind in items:
230
+ vals = (ratios.get(key,[]) + [None]*len(years))[:len(years)]
231
+ rows.append([label] + list(vals))
232
+ kinds = ["text"] + [items[0][2]]*len(years)
233
+ _table(doc, ["Metric"]+years, rows, kinds, currency, units_label)
234
+
235
+
236
+ def _dupont_section(doc, data):
237
+ _add_heading(doc, "DuPont Analysis", 1)
238
+ p = doc.add_paragraph()
239
+ r = p.add_run("ROE = Net Margin \u00d7 Asset Turnover \u00d7 Equity Multiplier")
240
+ r.font.size = Pt(11); r.font.bold = True; r.font.name = "Calibri"
241
+ years = data.get("years", [])
242
+ dp = data.get("dupont", {})
243
+ currency = data.get("currency", "INR")
244
+ rows = []
245
+ for label, key, kind in [
246
+ ("Net Margin","net_margin","pct"),("\u00d7 Asset Turnover","asset_turnover","ratio"),
247
+ ("\u00d7 Equity Multiplier","equity_multiplier","ratio"),("= ROE (DuPont)","roe_computed","pct"),
248
+ ("ROE (Reported)","roe_reported","pct"),
249
+ ]:
250
+ vals = (dp.get(key,[]) + [None]*len(years))[:len(years)]
251
+ rows.append([label]+list(vals))
252
+ _table(doc, ["Component"]+years, rows, ["text"]+["pct"]*len(years), currency)
253
+
254
+
255
+ def _peer_section(doc, data):
256
+ _add_heading(doc, "Peer Benchmarking", 1)
257
+ _add_text(doc, data.get("peer_comparison_commentary",""))
258
+ peers = data.get("peers", [])
259
+ currency = data.get("currency", "INR")
260
+ units_label = data.get("units_label", "Crores")
261
+ co = data.get("company",{}); ratios = data.get("ratios",{})
262
+ inc = data.get("income_statement",{}); val = data.get("valuation_snapshot",{})
263
+ def last(arr):
264
+ for v in reversed(arr or []):
265
+ if v is not None: return v
266
+ return None
267
+ headers = ["Company","Revenue","Op Margin","Net Margin","ROIC","D/E","FCF Margin","P/E"]
268
+ fmts = ["text","money","pct","pct","pct","ratio","pct","ratio"]
269
+ rows = [[
270
+ co.get("name",co.get("ticker",""))+" (Target)",
271
+ last(inc.get("revenue",[])), last(ratios.get("operating_margin",[])),
272
+ last(ratios.get("net_margin",[])), last(ratios.get("roic",[])),
273
+ last(ratios.get("debt_to_equity",[])), last(ratios.get("fcf_margin",[])),
274
+ val.get("pe_ttm"),
275
+ ]]
276
+ if not peers:
277
+ doc.add_paragraph("Peer comparison data not available for this run.")
278
+ for p in peers:
279
+ rows.append([p.get("name",p.get("ticker","")), p.get("revenue"), p.get("operating_margin"),
280
+ p.get("net_margin"), p.get("roic"), p.get("debt_to_equity"),
281
+ p.get("fcf_margin"), p.get("pe_ttm")])
282
+ if rows:
283
+ _table(doc, headers, rows, fmts, currency, units_label)
284
+
285
+
286
+ def _bullets(doc, items, heading=None, level=1):
287
+ if heading: _add_heading(doc, heading, level)
288
+ for item in items:
289
+ if isinstance(item, dict):
290
+ p = doc.add_paragraph(style="List Bullet")
291
+ r1 = p.add_run(f"{item.get('category','')}: "); r1.font.bold=True; r1.font.size=Pt(11); r1.font.name="Calibri"
292
+ r2 = p.add_run(item.get("description","")); r2.font.size=Pt(11); r2.font.name="Calibri"
293
+ else:
294
+ p = doc.add_paragraph(str(item), style="List Bullet")
295
+ for r in p.runs: r.font.size=Pt(11); r.font.name="Calibri"
296
+ p.paragraph_format.space_after = Pt(2)
297
+
298
+
299
+ def _sources_section(doc, data):
300
+ _add_heading(doc, "Sources", 1)
301
+ for i, src in enumerate(data.get("sources",[]), 1):
302
+ p = doc.add_paragraph()
303
+ p.add_run(f"{i}. ").font.size = Pt(10)
304
+ url_r = p.add_run(src.get("url",""))
305
+ url_r.font.size=Pt(10); url_r.font.color.rgb=RGBColor(0x05,0x63,0xC1); url_r.font.name="Calibri"
306
+ suffix = ""
307
+ if src.get("description"): suffix += f" — {src['description']}"
308
+ if src.get("retrieved"): suffix += f" (retrieved {src['retrieved']})"
309
+ if suffix:
310
+ sr = p.add_run(suffix); sr.font.size=Pt(10); sr.font.color.rgb=GRAY; sr.font.name="Calibri"
311
+
312
+
313
+ def _disclaimer(doc):
314
+ p = doc.add_paragraph()
315
+ r = p.add_run(
316
+ "Disclaimer: This report is generated from public financial data and AI analysis for informational "
317
+ "purposes only. It is not investment advice. Verify all figures against primary sources before "
318
+ "making any financial decisions."
319
+ )
320
+ r.font.size=Pt(9); r.font.italic=True; r.font.color.rgb=GRAY; r.font.name="Calibri"
321
+ p.paragraph_format.space_before=Pt(12)
322
+
323
+
324
+ # ── Orchestrator ──────────────────────────────────────────────────────────────
325
+
326
+ def build_document(data: dict, output_path: str) -> None:
327
+ doc = Document()
328
+ for section in doc.sections:
329
+ section.left_margin = section.right_margin = Cm(2.0)
330
+ section.top_margin = section.bottom_margin = Cm(2.0)
331
+
332
+ _cover(doc, data)
333
+
334
+ # Executive Summary
335
+ _add_heading(doc, "Executive Summary", 1)
336
+ _add_text(doc, data.get("executive_summary",""))
337
+
338
+ # Company & Industry Profile
339
+ _add_heading(doc, "Company Profile", 1)
340
+ _add_text(doc, data.get("company_overview",""))
341
+
342
+ _add_heading(doc, "Industry Profile", 1)
343
+ _add_text(doc, data.get("industry_profile",""))
344
+
345
+ _add_heading(doc, "Industry Trends", 1)
346
+ _add_text(doc, data.get("industry_trends",""))
347
+
348
+ _swot_section(doc, data)
349
+ _trends_section(doc, data)
350
+ _ratio_section(doc, data)
351
+ _dupont_section(doc, data)
352
+ _peer_section(doc, data)
353
+
354
+ if data.get("key_insights"):
355
+ _bullets(doc, data["key_insights"], "Key Insights", 1)
356
+ if data.get("risks"):
357
+ _bullets(doc, data["risks"], "Risks", 1)
358
+ if data.get("things_to_watch"):
359
+ _bullets(doc, data["things_to_watch"], "Things to Watch", 1)
360
+ if data.get("limitations_and_caveats"):
361
+ _bullets(doc, data["limitations_and_caveats"], "Limitations & Caveats", 1)
362
+
363
+ _sources_section(doc, data)
364
+ _disclaimer(doc)
365
+
366
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
367
+ doc.save(output_path)
368
+
369
+
370
+ if __name__ == "__main__":
371
+ import argparse
372
+ p = argparse.ArgumentParser()
373
+ p.add_argument("--input","-i",required=True)
374
+ p.add_argument("--output","-o",required=True)
375
+ args = p.parse_args()
376
+ with open(args.input, encoding="utf-8") as f:
377
+ data = json.load(f)
378
+ build_document(data, args.output)
379
+ print(f"Wrote: {args.output}")
builders/build_excel.py ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ builders/build_excel.py — Enhanced Excel workbook builder.
4
+ Reads the skill's original builder and adds DuPont, SWOT, and Indian unit support.
5
+ """
6
+ import json, sys, datetime
7
+ from pathlib import Path
8
+
9
+ try:
10
+ from openpyxl import Workbook
11
+ from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
12
+ from openpyxl.utils import get_column_letter
13
+ from openpyxl.chart import BarChart, LineChart, Reference
14
+ except ImportError:
15
+ print("ERROR: pip install openpyxl", file=sys.stderr); sys.exit(1)
16
+
17
+ # ── Style constants ───────────────────────────────────────────────────────────
18
+ HDR_FILL = PatternFill("solid", fgColor="1F4E78")
19
+ HDR_FONT = Font(name="Calibri", size=11, bold=True, color="FFFFFF")
20
+ SEC_FILL = PatternFill("solid", fgColor="D9E1F2")
21
+ SEC_FONT = Font(name="Calibri", size=11, bold=True, color="1F4E78")
22
+ LBL_FONT = Font(name="Calibri", size=11)
23
+ TTL_FONT = Font(name="Calibri", size=16, bold=True, color="1F4E78")
24
+ DAT_FONT = Font(name="Calibri", size=11)
25
+ GRN_FILL = PatternFill("solid", fgColor="C6EFCE")
26
+ RED_FILL = PatternFill("solid", fgColor="FFC7CE")
27
+ YLW_FILL = PatternFill("solid", fgColor="FFEB9C")
28
+
29
+ THIN = Border(
30
+ left=Side(style="thin", color="BFBFBF"), right=Side(style="thin", color="BFBFBF"),
31
+ top=Side(style="thin", color="BFBFBF"), bottom=Side(style="thin", color="BFBFBF"),
32
+ )
33
+ CENTER = Alignment(horizontal="center", vertical="center", wrap_text=True)
34
+ LEFT = Alignment(horizontal="left", vertical="center", wrap_text=True)
35
+ RIGHT = Alignment(horizontal="right", vertical="center")
36
+
37
+ FMT_MONEY = '#,##0;(#,##0);"—"'
38
+ FMT_DEC = '#,##0.00;(#,##0.00);"—"'
39
+ FMT_PCT = '0.0%;(0.0%);"—"'
40
+ FMT_RATIO = '0.00;(0.00);"—"'
41
+ FMT_DAYS = '0.0;"—"'
42
+
43
+ # ── Helpers ───────────────────────────────────────────────────────────────────
44
+
45
+ def _safe(arr, i, default=None):
46
+ try: return arr[i] if arr and arr[i] is not None else default
47
+ except: return default
48
+
49
+ def _hdr(ws, row, cols, start=1):
50
+ for i, h in enumerate(cols):
51
+ c = ws.cell(row=row, column=start+i, value=h)
52
+ c.font, c.fill, c.alignment, c.border = HDR_FONT, HDR_FILL, CENTER, THIN
53
+
54
+ def _sec(ws, row, label, ncols, start=1):
55
+ ws.cell(row=row, column=start, value=label).font = SEC_FONT
56
+ ws.cell(row=row, column=start).fill = SEC_FILL
57
+ ws.cell(row=row, column=start).alignment = LEFT
58
+ for col in range(start+1, start+ncols):
59
+ ws.cell(row=row, column=col).fill = SEC_FILL
60
+
61
+ def _data_row(ws, row, label, values, fmt=FMT_MONEY, start=1):
62
+ c = ws.cell(row=row, column=start, value=label)
63
+ c.font, c.alignment, c.border = LBL_FONT, LEFT, THIN
64
+ for i, v in enumerate(values):
65
+ cell = ws.cell(row=row, column=start+1+i, value=v if v is not None else "—")
66
+ cell.font, cell.alignment, cell.border = DAT_FONT, RIGHT, THIN
67
+ if v is not None and isinstance(v, (int, float)):
68
+ cell.number_format = fmt
69
+
70
+ def _title(ws, title, subtitle=None):
71
+ ws.cell(row=1, column=1, value=title).font = TTL_FONT
72
+ if subtitle:
73
+ ws.cell(row=2, column=1, value=subtitle).font = Font(name="Calibri", size=11, italic=True, color="595959")
74
+
75
+ def _widths(ws, widths):
76
+ for i, w in enumerate(widths):
77
+ ws.column_dimensions[get_column_letter(i+1)].width = w
78
+
79
+ def _freeze(ws, hdr_row):
80
+ ws.freeze_panes = ws.cell(row=hdr_row+1, column=2)
81
+
82
+ def _last(arr):
83
+ if not arr: return None
84
+ for v in reversed(arr):
85
+ if v is not None: return v
86
+ return None
87
+
88
+ # ── Statement section helper ──────────────────────────────────────────────────
89
+
90
+ def _stmt_section(ws, row, title, items, data_dict, years, fmt=FMT_MONEY):
91
+ _sec(ws, row, title, 1+len(years))
92
+ row += 1
93
+ for label, key in items:
94
+ vals = data_dict.get(key, [None]*len(years))
95
+ vals = (vals + [None]*len(years))[:len(years)]
96
+ _data_row(ws, row, label, vals, fmt)
97
+ row += 1
98
+ return row
99
+
100
+ # ── Sheet builders ────────────────────────────────────────────────────────────
101
+
102
+ def _summary_sheet(wb, data):
103
+ ws = wb.create_sheet("Summary")
104
+ co = data.get("company", {})
105
+ units_label = data.get("units_label", data.get("units", "Crores"))
106
+ currency = data.get("currency", co.get("currency", "INR"))
107
+ _title(ws, f"{co.get('name','')} ({co.get('ticker','')}) — Financial Analysis",
108
+ f"Report date: {co.get('report_date', datetime.date.today().isoformat())}")
109
+ r = 4
110
+ for label, val in [
111
+ ("Company", co.get("name","")),
112
+ ("Ticker", co.get("ticker","")),
113
+ ("Exchange", co.get("exchange","")),
114
+ ("Industry", co.get("industry","")),
115
+ ("Sector", co.get("sector","")),
116
+ ("Currency", currency),
117
+ ("Reporting Units", units_label),
118
+ ("Fiscal Year End", co.get("fiscal_year_end","")),
119
+ ("Analysis Period", f"{len(data.get('years',[]))} years ({', '.join(data.get('years',[]))})"),
120
+ ("Share Price", co.get("share_price")),
121
+ ("Market Cap", co.get("market_cap")),
122
+ ("Data Source", data.get("source", "Yahoo Finance (yfinance)")),
123
+ ("Models Used", ", ".join(data.get("models_used", []) or ["None"])),
124
+ ("Warnings", " | ".join(data.get("warnings", []))),
125
+ ]:
126
+ ws.cell(row=r, column=1, value=label).font = LBL_FONT
127
+ ws.cell(row=r, column=2, value=val).font = DAT_FONT
128
+ r += 1
129
+ _widths(ws, [28, 50])
130
+
131
+
132
+ def _income_sheet(wb, data):
133
+ ws = wb.create_sheet("Income Statement")
134
+ co, years = data.get("company",{}), data.get("years",[])
135
+ units_label = data.get("units_label", data.get("units", "Crores"))
136
+ currency = data.get("currency", co.get("currency", "INR"))
137
+ _title(ws, f"Income Statement — {co.get('ticker','')}",
138
+ f"All figures in {units_label}")
139
+ _hdr(ws, 4, [f"({units_label})"] + years)
140
+ inc = data.get("income_statement", {})
141
+ r = _stmt_section(ws, 5, "Revenue & Profitability", [
142
+ ("Revenue", "revenue"),
143
+ ("Cost of Revenue", "cost_of_revenue"),
144
+ ("Gross Profit", "gross_profit"),
145
+ ("Operating Expenses", "operating_expenses"),
146
+ ("Operating Income (EBIT)","operating_income"),
147
+ ("D&A", "depreciation_amort"),
148
+ ("EBITDA", "ebitda"),
149
+ ("Interest Expense", "interest_expense"),
150
+ ("Pretax Income", "pretax_income"),
151
+ ("Tax Expense", "tax_expense"),
152
+ ("Net Income", "net_income"),
153
+ ], inc, years)
154
+ r += 1
155
+ _sec(ws, r, "Per-Share Data", 1+len(years)); r += 1
156
+ _data_row(ws, r, "Diluted EPS", inc.get("diluted_eps",[None]*len(years))[:len(years)], FMT_DEC); r += 1
157
+ _data_row(ws, r, "Diluted Shares (M)", inc.get("diluted_shares",[None]*len(years))[:len(years)], FMT_MONEY)
158
+
159
+ # Add revenue bar chart
160
+ if len(years) >= 2:
161
+ chart = BarChart()
162
+ chart.title = f"Revenue Trend ({units_label})"
163
+ chart.style = 10
164
+ chart.y_axis.title = units_label
165
+ rev_row = 6
166
+ data_ref = Reference(ws, min_col=2, max_col=1+len(years), min_row=rev_row, max_row=rev_row)
167
+ cats_ref = Reference(ws, min_col=2, max_col=1+len(years), min_row=4, max_row=4)
168
+ chart.add_data(data_ref)
169
+ chart.set_categories(cats_ref)
170
+ chart.shape = 4
171
+ ws.add_chart(chart, f"A{r+3}")
172
+
173
+ _widths(ws, [34]+[16]*len(years)); _freeze(ws, 4)
174
+
175
+
176
+ def _balance_sheet(wb, data):
177
+ ws = wb.create_sheet("Balance Sheet")
178
+ co, years = data.get("company",{}), data.get("years",[])
179
+ units_label = data.get("units_label", data.get("units", "Crores"))
180
+ _title(ws, f"Balance Sheet — {co.get('ticker','')}",
181
+ f"As of fiscal year-end. {units_label}")
182
+ _hdr(ws, 4, [f"({units_label})"] + years)
183
+ bs = data.get("balance_sheet", {})
184
+ r = 5
185
+ for sec_title, items in [
186
+ ("Current Assets", [
187
+ ("Cash & Equivalents","cash_and_equivalents"),("Short-term Investments","short_term_investments"),
188
+ ("Receivables","receivables"),("Inventory","inventory"),("Other Current Assets","other_current_assets"),
189
+ ("Total Current Assets","total_current_assets"),
190
+ ]),
191
+ ("Non-Current Assets", [
192
+ ("PP&E (Net)","ppe_net"),("Goodwill","goodwill"),("Intangibles","intangibles"),
193
+ ("Other LT Assets","other_long_term_assets"),("Total Assets","total_assets"),
194
+ ]),
195
+ ("Current Liabilities", [
196
+ ("Accounts Payable","accounts_payable"),("Short-term Debt","short_term_debt"),
197
+ ("Other Current Liabilities","other_current_liabilities"),
198
+ ("Total Current Liabilities","total_current_liabilities"),
199
+ ]),
200
+ ("Non-Current Liabilities & Equity", [
201
+ ("Long-term Debt","long_term_debt"),("Other LT Liabilities","other_long_term_liabilities"),
202
+ ("Total Liabilities","total_liabilities"),("Total Equity","total_equity"),
203
+ ("Total Liabilities + Equity","total_liab_and_equity"),
204
+ ]),
205
+ ]:
206
+ r = _stmt_section(ws, r, sec_title, items, bs, years)
207
+ _widths(ws, [36]+[16]*len(years)); _freeze(ws, 4)
208
+
209
+
210
+ def _cashflow_sheet(wb, data):
211
+ ws = wb.create_sheet("Cash Flow")
212
+ co, years = data.get("company",{}), data.get("years",[])
213
+ units_label = data.get("units_label", data.get("units", "Crores"))
214
+ _title(ws, f"Cash Flow Statement — {co.get('ticker','')}",
215
+ f"All figures in {units_label}")
216
+ _hdr(ws, 4, [f"({units_label})"] + years)
217
+ cf = data.get("cash_flow", {})
218
+ r = 5
219
+ for sec_title, items in [
220
+ ("Operating Activities", [
221
+ ("Net Income","net_income"),("D&A","depreciation_amort"),("Stock-Based Comp","stock_based_comp"),
222
+ ("Working Capital Changes","working_capital_changes"),("Cash from Operations","cash_from_operations"),
223
+ ]),
224
+ ("Investing Activities", [
225
+ ("Capital Expenditures","capex"),("Acquisitions","acquisitions"),("Cash from Investing","cash_from_investing"),
226
+ ]),
227
+ ("Financing Activities", [
228
+ ("Debt Issuance/(Repayment)","debt_issuance_repayment"),("Buybacks","stock_buybacks"),
229
+ ("Dividends Paid","dividends_paid"),("Cash from Financing","cash_from_financing"),
230
+ ]),
231
+ ("Summary", [
232
+ ("Net Change in Cash","net_change_in_cash"),("Free Cash Flow","free_cash_flow"),
233
+ ]),
234
+ ]:
235
+ r = _stmt_section(ws, r, sec_title, items, cf, years)
236
+ _widths(ws, [36]+[16]*len(years)); _freeze(ws, 4)
237
+
238
+
239
+ RATIO_SECTIONS = [
240
+ ("Profitability", [
241
+ ("gross_margin","Gross Margin",FMT_PCT),("operating_margin","Operating Margin",FMT_PCT),
242
+ ("ebitda_margin","EBITDA Margin",FMT_PCT),("net_margin","Net Margin",FMT_PCT),
243
+ ("roe","ROE",FMT_PCT),("roa","ROA",FMT_PCT),("roic","ROIC",FMT_PCT),
244
+ ]),
245
+ ("Liquidity", [
246
+ ("current_ratio","Current Ratio",FMT_RATIO),("quick_ratio","Quick Ratio",FMT_RATIO),
247
+ ("cash_ratio","Cash Ratio",FMT_RATIO),
248
+ ]),
249
+ ("Solvency / Leverage", [
250
+ ("debt_to_equity","Debt-to-Equity",FMT_RATIO),("debt_to_assets","Debt-to-Assets",FMT_RATIO),
251
+ ("net_debt_to_ebitda","Net Debt/EBITDA",FMT_RATIO),("interest_coverage","Interest Coverage",FMT_RATIO),
252
+ ]),
253
+ ("Efficiency", [
254
+ ("asset_turnover","Asset Turnover",FMT_RATIO),("inventory_turnover","Inventory Turnover",FMT_RATIO),
255
+ ("days_inventory","Days Inventory (DIO)",FMT_DAYS),("days_sales_outstanding","DSO",FMT_DAYS),
256
+ ("days_payables","DPO",FMT_DAYS),("cash_conversion_cycle","Cash Conversion Cycle",FMT_DAYS),
257
+ ]),
258
+ ("Cash Flow Quality", [
259
+ ("ocf_to_net_income","OCF/Net Income",FMT_RATIO),("fcf_margin","FCF Margin",FMT_PCT),
260
+ ("fcf_conversion","FCF Conversion",FMT_RATIO),("capex_intensity","Capex/Revenue",FMT_PCT),
261
+ ]),
262
+ ]
263
+
264
+
265
+ def _ratios_sheet(wb, data):
266
+ ws = wb.create_sheet("Ratios")
267
+ years = data.get("years", [])
268
+ _title(ws, f"Financial Ratios — {data.get('company',{}).get('ticker','')}", "5-year trend")
269
+ _hdr(ws, 4, ["Ratio"] + years)
270
+ ratios = data.get("ratios", {})
271
+ r = 5
272
+ for sec_name, items in RATIO_SECTIONS:
273
+ _sec(ws, r, sec_name, 1+len(years)); r += 1
274
+ for key, label, fmt in items:
275
+ vals = ratios.get(key, [None]*len(years))
276
+ vals = (vals + [None]*len(years))[:len(years)]
277
+ _data_row(ws, r, label, vals, fmt); r += 1
278
+ _widths(ws, [40]+[14]*len(years)); _freeze(ws, 4)
279
+
280
+
281
+ def _dupont_sheet(wb, data):
282
+ ws = wb.create_sheet("DuPont Analysis")
283
+ years = data.get("years", [])
284
+ _title(ws, "DuPont Decomposition", "ROE = Net Margin × Asset Turnover × Equity Multiplier")
285
+ _hdr(ws, 4, ["Component"] + years)
286
+ dp = data.get("dupont", {})
287
+ r = 5
288
+ for label, key, fmt in [
289
+ ("Net Margin", "net_margin", FMT_PCT),
290
+ ("× Asset Turnover", "asset_turnover", FMT_RATIO),
291
+ ("× Equity Multiplier","equity_multiplier",FMT_RATIO),
292
+ ("= ROE (DuPont)", "roe_computed", FMT_PCT),
293
+ ("ROE (Reported)", "roe_reported", FMT_PCT),
294
+ ]:
295
+ vals = dp.get(key, [None]*len(years))
296
+ vals = (vals + [None]*len(years))[:len(years)]
297
+ _data_row(ws, r, label, vals, fmt)
298
+ r += 1
299
+ _widths(ws, [28]+[14]*len(years))
300
+
301
+
302
+ def _growth_sheet(wb, data):
303
+ ws = wb.create_sheet("Growth Metrics")
304
+ years = data.get("years", [])
305
+ yoy_hdrs = [f"{years[i]} vs {years[i-1]}" for i in range(1, len(years))] if len(years) > 1 else []
306
+ _title(ws, "Growth Metrics", "YoY growth and CAGRs")
307
+ _hdr(ws, 4, ["Metric"] + yoy_hdrs + ["CAGR"])
308
+ g = data.get("growth_metrics", {})
309
+ r = 5
310
+ for label, yoy_key, cagr_key in [
311
+ ("Revenue", "revenue_yoy", "revenue_cagr_5y"),
312
+ ("Operating Income","operating_income_yoy","operating_income_cagr_5y"),
313
+ ("Net Income", "net_income_yoy", "net_income_cagr_5y"),
314
+ ("Diluted EPS", "eps_yoy", "eps_cagr_5y"),
315
+ ("Free Cash Flow", "fcf_yoy", "fcf_cagr_5y"),
316
+ ]:
317
+ yoy = g.get(yoy_key, [])[1:] # skip first None
318
+ cagr = g.get(cagr_key)
319
+ vals = (yoy + [None]*len(yoy_hdrs))[:len(yoy_hdrs)] + [cagr]
320
+ _data_row(ws, r, label, vals, FMT_PCT); r += 1
321
+ _widths(ws, [28]+[16]*(len(yoy_hdrs)+1))
322
+
323
+
324
+ def _peers_sheet(wb, data):
325
+ ws = wb.create_sheet("Peer Comparison")
326
+ co = data.get("company", {})
327
+ _title(ws, f"Peer Comparison — {co.get('ticker','')}", "Latest fiscal year")
328
+ cols = ["Company","Ticker","Revenue","Gross Margin","Op Margin","Net Margin",
329
+ "ROE","ROIC","D/E","Current Ratio","FCF Margin","P/E","EV/EBITDA","Rationale"]
330
+ _hdr(ws, 4, cols)
331
+ r = 5
332
+ years = data.get("years", [])
333
+ rat = data.get("ratios", {}); inc = data.get("income_statement", {})
334
+ val = data.get("valuation_snapshot", {})
335
+ # Target row
336
+ target = [
337
+ co.get("name",""), co.get("ticker",""),
338
+ _last(inc.get("revenue",[])), _last(rat.get("gross_margin",[])),
339
+ _last(rat.get("operating_margin",[])), _last(rat.get("net_margin",[])),
340
+ _last(rat.get("roe",[])), _last(rat.get("roic",[])),
341
+ _last(rat.get("debt_to_equity",[])), _last(rat.get("current_ratio",[])),
342
+ _last(rat.get("fcf_margin",[])), val.get("pe_ttm"), val.get("ev_ebitda"),
343
+ "Target company",
344
+ ]
345
+ fmts = ["","","money","pct","pct","pct","pct","pct","ratio","ratio","pct","ratio","ratio",""]
346
+ _write_peer_row(ws, r, target, fmts, is_target=True); r += 1
347
+ for p in data.get("peers", []):
348
+ peer_row = [
349
+ p.get("name",""), p.get("ticker",""), p.get("revenue"),
350
+ p.get("gross_margin"), p.get("operating_margin"), p.get("net_margin"),
351
+ p.get("roe"), p.get("roic"), p.get("debt_to_equity"), p.get("current_ratio"),
352
+ p.get("fcf_margin"), p.get("pe_ttm"), p.get("ev_ebitda"), p.get("rationale",""),
353
+ ]
354
+ _write_peer_row(ws, r, peer_row, fmts); r += 1
355
+ _widths(ws, [24,10,16,12,12,12,10,10,10,12,12,10,12,50])
356
+
357
+
358
+ def _write_peer_row(ws, row, values, fmts, is_target=False):
359
+ fmt_map = {"money":FMT_MONEY,"pct":FMT_PCT,"ratio":FMT_RATIO}
360
+ fill = PatternFill("solid", fgColor="FFF2CC") if is_target else None
361
+ for i, v in enumerate(values):
362
+ c = ws.cell(row=row, column=i+1, value=v if v is not None else "—")
363
+ c.border = THIN
364
+ c.font = Font(name="Calibri", size=11, bold=is_target)
365
+ if fill: c.fill = fill
366
+ fmt = fmts[i] if i < len(fmts) else ""
367
+ if fmt in fmt_map and isinstance(v, (int, float)):
368
+ c.number_format = fmt_map[fmt]
369
+ c.alignment = RIGHT
370
+ else:
371
+ c.alignment = LEFT
372
+
373
+
374
+ def _swot_sheet(wb, data):
375
+ ws = wb.create_sheet("SWOT Analysis")
376
+ _title(ws, "SWOT Analysis", "Strengths, Weaknesses, Opportunities, Threats")
377
+ swot = data.get("swot", {})
378
+ r = 4
379
+ colors = {"Strengths":"C6EFCE","Weaknesses":"FFC7CE","Opportunities":"DDEBF7","Threats":"FFEB9C"}
380
+ for category, key in [("Strengths","strengths"),("Weaknesses","weaknesses"),
381
+ ("Opportunities","opportunities"),("Threats","threats")]:
382
+ fill = PatternFill("solid", fgColor=colors[category])
383
+ hdr_cell = ws.cell(row=r, column=1, value=category)
384
+ hdr_cell.font = Font(name="Calibri", size=12, bold=True)
385
+ hdr_cell.fill = fill
386
+ r += 1
387
+ for item in swot.get(key, []):
388
+ c = ws.cell(row=r, column=1, value=f"• {item}")
389
+ c.font = Font(name="Calibri", size=11)
390
+ c.alignment = Alignment(horizontal="left", vertical="top", wrap_text=True)
391
+ ws.row_dimensions[r].height = 32
392
+ r += 1
393
+ r += 1
394
+ _widths(ws, [90])
395
+
396
+
397
+ def _sources_sheet(wb, data):
398
+ ws = wb.create_sheet("Sources")
399
+ _title(ws, "Sources", "All data sources and retrieval dates")
400
+ _hdr(ws, 4, ["#","URL","Description","Retrieved"])
401
+ r = 5
402
+ for i, src in enumerate(data.get("sources",[]), start=1):
403
+ ws.cell(row=r, column=1, value=i).alignment = CENTER
404
+ url = src.get("url","")
405
+ uc = ws.cell(row=r, column=2, value=url)
406
+ uc.font = Font(name="Calibri", size=10, color="0563C1", underline="single")
407
+ uc.alignment = LEFT
408
+ if url:
409
+ try: uc.hyperlink = url
410
+ except: pass
411
+ ws.cell(row=r, column=3, value=src.get("description","")).alignment = LEFT
412
+ ws.cell(row=r, column=4, value=src.get("retrieved","")).alignment = CENTER
413
+ for col in range(1,5): ws.cell(row=r,column=col).border = THIN
414
+ ws.row_dimensions[r].height = 28; r += 1
415
+ _widths(ws, [6,70,50,16])
416
+
417
+
418
+ # ── Orchestrator ──────────────────────────────────────────────────────────────
419
+
420
+ def build_workbook(data: dict, output_path: str) -> None:
421
+ wb = Workbook()
422
+ wb.remove(wb.active)
423
+ _summary_sheet(wb, data)
424
+ _income_sheet(wb, data)
425
+ _balance_sheet(wb, data)
426
+ _cashflow_sheet(wb, data)
427
+ _ratios_sheet(wb, data)
428
+ _dupont_sheet(wb, data)
429
+ _growth_sheet(wb, data)
430
+ _peers_sheet(wb, data)
431
+ _swot_sheet(wb, data)
432
+ _sources_sheet(wb, data)
433
+ wb.save(output_path)
434
+
435
+
436
+ if __name__ == "__main__":
437
+ import argparse
438
+ p = argparse.ArgumentParser()
439
+ p.add_argument("--input", "-i", required=True)
440
+ p.add_argument("--output", "-o", required=True)
441
+ args = p.parse_args()
442
+ with open(args.input, encoding="utf-8") as f:
443
+ data = json.load(f)
444
+ Path(args.output).parent.mkdir(parents=True, exist_ok=True)
445
+ build_workbook(data, args.output)
446
+ print(f"Wrote: {args.output}")
builders/build_pptx.py ADDED
@@ -0,0 +1,423 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ builders/build_pptx.py — PowerPoint presentation builder.
3
+
4
+ Generates a widescreen (16:9) PPTX presentation summarizing the financial analysis
5
+ and AI narrative, strictly matching user-defined theme, colors, and layout structure.
6
+ """
7
+ import os
8
+ import sys
9
+ import datetime
10
+ from pathlib import Path
11
+ from typing import Dict, List
12
+
13
+ # import python-pptx classes
14
+ from pptx import Presentation
15
+ from pptx.util import Inches, Pt
16
+ from pptx.dml.color import RGBColor
17
+ from pptx.enum.text import PP_ALIGN
18
+ from pptx.enum.shapes import MSO_SHAPE
19
+ from pptx.enum.shapes import MSO_CONNECTOR
20
+
21
+ # Color Palette defined in User Rules
22
+ COLOR_DARK_TEXT = RGBColor(0, 0, 0) # 000000 (Black)
23
+ COLOR_LIGHT_BG = RGBColor(255, 255, 255) # FFFFFF (White)
24
+ COLOR_DARK_GREEN = RGBColor(0, 132, 0) # 008400 (Green Bar 1)
25
+ COLOR_LIGHT_GREEN = RGBColor(149, 203, 137) # 95CB89 (Green Bar 2)
26
+ COLOR_GRAY_LINE = RGBColor(0, 0, 0) # 000000 (Black bottom line)
27
+ COLOR_DARK_SLATE = RGBColor(69, 95, 81) # 455F51 (Dark slate green)
28
+ COLOR_LIGHT_GRAY = RGBColor(227, 222, 209) # E3DED1 (Light warm gray/beige)
29
+ COLOR_ACCENT = RGBColor(84, 158, 57) # 549E39 (Accent green)
30
+
31
+ FONT_MAJOR = "Helvetica Neue Bold"
32
+ FONT_MINOR = "Helvetica Neue Light"
33
+ FONT_MAJOR_FALLBACK = "Arial"
34
+ FONT_MINOR_FALLBACK = "Arial"
35
+
36
+ def _apply_theme_decorations(slide, prs):
37
+ """
38
+ Applies the layout rectangles and bottom line strictly defined in the
39
+ Slide Master Layout.
40
+ """
41
+ # 1. Decorative top-left dark green bar
42
+ # rect: x: 0.15, y: 0.0, w: 0.36, h: 1.16
43
+ rect1 = slide.shapes.add_shape(
44
+ MSO_SHAPE.RECTANGLE,
45
+ Inches(0.15), Inches(0.0), Inches(0.36), Inches(1.16)
46
+ )
47
+ rect1.fill.solid()
48
+ rect1.fill.fore_color.rgb = COLOR_DARK_GREEN
49
+ rect1.line.fill.background() # No border line
50
+
51
+ # 2. Decorative top-left light green bar
52
+ # rect: x: 0.55, y: 0.0, w: 0.11, h: 1.39
53
+ rect2 = slide.shapes.add_shape(
54
+ MSO_SHAPE.RECTANGLE,
55
+ Inches(0.55), Inches(0.0), Inches(0.11), Inches(1.39)
56
+ )
57
+ rect2.fill.solid()
58
+ rect2.fill.fore_color.rgb = COLOR_LIGHT_GREEN
59
+ rect2.line.fill.background() # No border line
60
+
61
+ # 3. Decorative bottom line
62
+ # line: x: 0.67, y: 6.83, w: 12.0, h: 0.0
63
+ connector = slide.shapes.add_connector(
64
+ MSO_CONNECTOR.STRAIGHT,
65
+ Inches(0.67), Inches(6.83), Inches(12.67), Inches(6.83)
66
+ )
67
+ connector.line.color.rgb = COLOR_GRAY_LINE
68
+ connector.line.width = Pt(1)
69
+
70
+ def _add_slide_title(slide, text: str):
71
+ """
72
+ Adds the Slide Title using Layout 7 Title configurations:
73
+ placeholder: x: 0.81, y: 0.29, w: 11.74, h: 0.38, align: left, valign: top
74
+ """
75
+ tx_box = slide.shapes.add_textbox(
76
+ Inches(0.81), Inches(0.29), Inches(11.74), Inches(0.38)
77
+ )
78
+ tf = tx_box.text_frame
79
+ tf.word_wrap = True
80
+ tf.margin_top = 0
81
+ tf.margin_bottom = 0
82
+ tf.margin_left = 0
83
+ tf.margin_right = 0
84
+
85
+ p = tf.paragraphs[0]
86
+ p.text = text
87
+ p.font.name = FONT_MAJOR
88
+ p.font.size = Pt(20)
89
+ p.font.bold = True
90
+ p.font.color.rgb = COLOR_DARK_TEXT
91
+ p.alignment = PP_ALIGN.LEFT
92
+
93
+ def _add_bullet_points(slide, bullets: List[str], top_inch=1.56, height_inch=5.14):
94
+ """
95
+ Adds bullet points body text block using Master 2 Body:
96
+ placeholder: x: 0.67, y: top, w: 12.0, h: height
97
+ """
98
+ tx_box = slide.shapes.add_textbox(
99
+ Inches(0.67), Inches(top_inch), Inches(12.0), Inches(height_inch)
100
+ )
101
+ tf = tx_box.text_frame
102
+ tf.word_wrap = True
103
+ tf.margin_top = 0
104
+ tf.margin_bottom = 0
105
+ tf.margin_left = 0
106
+ tf.margin_right = 0
107
+
108
+ for i, b in enumerate(bullets):
109
+ p = tf.add_paragraph() if i > 0 else tf.paragraphs[0]
110
+ p.text = "• " + b
111
+ p.font.name = FONT_MINOR
112
+ p.font.size = Pt(15)
113
+ p.font.color.rgb = COLOR_DARK_TEXT
114
+ p.space_after = Pt(8)
115
+ p.line_spacing = 1.2
116
+
117
+ def _style_table_cell(cell, text: str, font_name=FONT_MINOR, font_size=11, bold=False, text_color=COLOR_DARK_TEXT, bg_color=None, align=PP_ALIGN.CENTER):
118
+ """Styles cell texts and background fills."""
119
+ cell.text = text
120
+ p = cell.text_frame.paragraphs[0]
121
+ p.alignment = align
122
+ p.font.name = font_name
123
+ p.font.size = Pt(font_size)
124
+ p.font.bold = bold
125
+ p.font.color.rgb = text_color
126
+
127
+ if bg_color:
128
+ cell.fill.solid()
129
+ cell.fill.fore_color.rgb = bg_color
130
+
131
+ def build_presentation(data: Dict, output_path: str):
132
+ """
133
+ Assembles a widescreen PowerPoint report from scraped financial data and AI models output.
134
+ """
135
+ prs = Presentation()
136
+
137
+ # Strictly configure widescreen 16:9 aspect ratio
138
+ prs.slide_width = Inches(13.333)
139
+ prs.slide_height = Inches(7.5)
140
+
141
+ blank_layout = prs.slide_layouts[6] # Blank slide template
142
+
143
+ company_name = data.get("company", {}).get("name", "Unknown Company")
144
+ ticker = data.get("company", {}).get("ticker", "N/A")
145
+ industry = data.get("company", {}).get("industry", "N/A")
146
+ sector = data.get("company", {}).get("sector", "N/A")
147
+ date_str = data.get("company", {}).get("report_date", datetime.date.today().isoformat())
148
+ years = data.get("years", [])
149
+
150
+ # ==========================================
151
+ # SLIDE 1: Title Slide
152
+ # ==========================================
153
+ slide_title = prs.slides.add_slide(blank_layout)
154
+ _apply_theme_decorations(slide_title, prs)
155
+
156
+ # Title Text Frame
157
+ title_box = slide_title.shapes.add_textbox(
158
+ Inches(1.0), Inches(2.2), Inches(11.3), Inches(2.0)
159
+ )
160
+ tf = title_box.text_frame
161
+ tf.word_wrap = True
162
+ p1 = tf.paragraphs[0]
163
+ p1.text = company_name
164
+ p1.font.name = FONT_MAJOR
165
+ p1.font.size = Pt(40)
166
+ p1.font.bold = True
167
+ p1.font.color.rgb = COLOR_DARK_TEXT
168
+
169
+ p2 = tf.add_paragraph()
170
+ p2.text = f"Financial Analysis & Investment Insights ({ticker})"
171
+ p2.font.name = FONT_MINOR
172
+ p2.font.size = Pt(22)
173
+ p2.font.color.rgb = COLOR_DARK_SLATE
174
+ p2.space_before = Pt(8)
175
+
176
+ # Subtitle Info (Date / Authors)
177
+ sub_box = slide_title.shapes.add_textbox(
178
+ Inches(1.0), Inches(5.0), Inches(11.3), Inches(1.2)
179
+ )
180
+ tf_sub = sub_box.text_frame
181
+ p_sub = tf_sub.paragraphs[0]
182
+ p_sub.text = f"Report Date: {date_str} | Sector: {sector} | Industry: {industry}"
183
+ p_sub.font.name = FONT_MINOR
184
+ p_sub.font.size = Pt(13)
185
+ p_sub.font.color.rgb = COLOR_DARK_SLATE
186
+
187
+ p_sub2 = tf_sub.add_paragraph()
188
+ p_sub2.text = "Disclaimer: This report is for informational purposes only and does not constitute investment advice."
189
+ p_sub2.font.name = FONT_MINOR
190
+ p_sub2.font.size = Pt(11)
191
+ p_sub2.font.color.rgb = COLOR_DARK_SLATE
192
+ p_sub2.space_before = Pt(6)
193
+
194
+ # ==========================================
195
+ # SLIDE 2: Executive Summary
196
+ # ==========================================
197
+ exec_summary_text = data.get("executive_summary", "")
198
+ if exec_summary_text and len(exec_summary_text) > 50:
199
+ slide_exec = prs.slides.add_slide(blank_layout)
200
+ _apply_theme_decorations(slide_exec, prs)
201
+ _add_slide_title(slide_exec, "Executive Summary")
202
+
203
+ # Clean up text and divide into bullet blocks
204
+ paragraphs = [p.strip() for p in exec_summary_text.split('\n') if p.strip()]
205
+ _add_bullet_points(slide_exec, paragraphs[:5])
206
+
207
+ # ==========================================
208
+ # SLIDE 3: Key Financial Highlights Table
209
+ # ==========================================
210
+ if years:
211
+ slide_metrics = prs.slides.add_slide(blank_layout)
212
+ _apply_theme_decorations(slide_metrics, prs)
213
+ _add_slide_title(slide_metrics, "Key Financial Performance")
214
+
215
+ # Build Grid Table
216
+ cols = len(years) + 1
217
+ rows = 6
218
+ table_shape = slide_metrics.shapes.add_table(
219
+ rows, cols, Inches(0.8), Inches(1.6), Inches(11.7), Inches(3.8)
220
+ )
221
+ table = table_shape.table
222
+
223
+ # Column Headers
224
+ _style_table_cell(table.cell(0, 0), "Financial Metric", FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE, PP_ALIGN.LEFT)
225
+ for idx, y in enumerate(years):
226
+ _style_table_cell(table.cell(0, idx+1), str(y), FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE)
227
+
228
+ metrics = [
229
+ ("Revenue", "income_statement", 1.0),
230
+ ("Gross Profit", "income_statement", 1.0),
231
+ ("Operating Income (EBIT)", "income_statement", 1.0),
232
+ ("Net Income", "income_statement", 1.0),
233
+ ("Operating Cash Flow (OCF)", "cash_flow", 1.0)
234
+ ]
235
+
236
+ currency = data.get("company", {}).get("currency", "USD")
237
+ units = data.get("company", {}).get("units", "millions")
238
+
239
+ for r_idx, (m_label, sheet_name, scale) in enumerate(metrics):
240
+ curr_row = r_idx + 1
241
+ bg_color = COLOR_LIGHT_GRAY if curr_row % 2 == 1 else COLOR_LIGHT_BG
242
+
243
+ # Label cell
244
+ _style_table_cell(table.cell(curr_row, 0), f"{m_label} ({currency} {units.capitalize()})", FONT_MAJOR, 11, False, COLOR_DARK_TEXT, bg_color, PP_ALIGN.LEFT)
245
+
246
+ # Values cells
247
+ sheet_data = data.get(sheet_name, {})
248
+ val_list = sheet_data.get(m_label, [])
249
+ for c_idx, y in enumerate(years):
250
+ val_str = "N/A"
251
+ if c_idx < len(val_list) and val_list[c_idx] is not None:
252
+ val_str = f"{val_list[c_idx]:,.1f}"
253
+ _style_table_cell(table.cell(curr_row, c_idx+1), val_str, FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color)
254
+
255
+ # Brief Narrative summary underneath table
256
+ rev_commentary = data.get("trend_analysis", {}).get("revenue_and_profitability", "")
257
+ if rev_commentary:
258
+ comment_box = slide_metrics.shapes.add_textbox(Inches(0.8), Inches(5.6), Inches(11.7), Inches(1.1))
259
+ tf_c = comment_box.text_frame
260
+ tf_c.word_wrap = True
261
+ p_c = tf_c.paragraphs[0]
262
+ p_c.text = "Key Takeaway: " + (rev_commentary[:260] + "..." if len(rev_commentary) > 260 else rev_commentary)
263
+ p_c.font.name = FONT_MINOR
264
+ p_c.font.size = Pt(12)
265
+ p_c.font.italic = True
266
+ p_c.font.color.rgb = COLOR_DARK_SLATE
267
+
268
+ # ==========================================
269
+ # SLIDE 4: Dupont ROE Decomposition
270
+ # ==========================================
271
+ dp_data = data.get("dupont", {})
272
+ if dp_data and "years" in dp_data:
273
+ slide_dp = prs.slides.add_slide(blank_layout)
274
+ _apply_theme_decorations(slide_dp, prs)
275
+ _add_slide_title(slide_dp, "DuPont ROE Analysis (Latest 3 Years)")
276
+
277
+ dp_years = dp_data.get("years", [])
278
+ cols = len(dp_years) + 1
279
+ rows = 5
280
+ table_shape = slide_dp.shapes.add_table(
281
+ rows, cols, Inches(0.8), Inches(1.6), Inches(11.7), Inches(3.2)
282
+ )
283
+ table = table_shape.table
284
+
285
+ # Headers
286
+ _style_table_cell(table.cell(0, 0), "DuPont Component", FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE, PP_ALIGN.LEFT)
287
+ for idx, y in enumerate(dp_years):
288
+ _style_table_cell(table.cell(0, idx+1), str(y), FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE)
289
+
290
+ components = [
291
+ ("Net Profit Margin (%)", "Net Margin"),
292
+ ("Asset Turnover (x)", "Asset Turnover"),
293
+ ("Equity Multiplier (x)", "Equity Multiplier"),
294
+ ("Return on Equity (ROE %)", "ROE")
295
+ ]
296
+
297
+ for r_idx, (c_label, key) in enumerate(components):
298
+ curr_row = r_idx + 1
299
+ bg_color = COLOR_LIGHT_GRAY if curr_row % 2 == 1 else COLOR_LIGHT_BG
300
+ is_bold = (curr_row == 4) # Bold ROE row
301
+
302
+ _style_table_cell(table.cell(curr_row, 0), c_label, FONT_MAJOR if is_bold else FONT_MINOR, 11, is_bold, COLOR_DARK_TEXT, bg_color, PP_ALIGN.LEFT)
303
+
304
+ vals_list = dp_data.get(key, [])
305
+ for c_idx, y in enumerate(dp_years):
306
+ val_str = "N/A"
307
+ if c_idx < len(vals_list) and vals_list[c_idx] is not None:
308
+ val_val = vals_list[c_idx]
309
+ if "%" in c_label or "ROE" in key:
310
+ val_str = f"{val_val * 100:.1f}%" if val_val < 1.0 else f"{val_val:.1f}%"
311
+ else:
312
+ val_str = f"{val_val:.2f}x"
313
+ _style_table_cell(table.cell(curr_row, c_idx+1), val_str, FONT_MAJOR if is_bold else FONT_MINOR, 11, is_bold, COLOR_DARK_TEXT, bg_color)
314
+
315
+ # DuPont Commentary Text Box
316
+ commentary = data.get("trend_analysis", {}).get("balance_sheet_strength", "")
317
+ if commentary:
318
+ comment_box = slide_dp.shapes.add_textbox(Inches(0.8), Inches(5.0), Inches(11.7), Inches(1.6))
319
+ tf_c = comment_box.text_frame
320
+ tf_c.word_wrap = True
321
+ p_c = tf_c.paragraphs[0]
322
+ p_c.text = "DuPont Drivers & Balance Sheet Strength: " + (commentary[:380] + "..." if len(commentary) > 380 else commentary)
323
+ p_c.font.name = FONT_MINOR
324
+ p_c.font.size = Pt(12)
325
+ p_c.font.color.rgb = COLOR_DARK_TEXT
326
+ p_c.line_spacing = 1.2
327
+
328
+ # ==========================================
329
+ # SLIDE 5: Competitor Benchmarking
330
+ # ==========================================
331
+ peers_list = data.get("peers", [])
332
+ if peers_list:
333
+ slide_peers = prs.slides.add_slide(blank_layout)
334
+ _apply_theme_decorations(slide_peers, prs)
335
+ _add_slide_title(slide_peers, "Competitor Benchmarking")
336
+
337
+ # Build Grid Table
338
+ cols = 5
339
+ rows = min(len(peers_list) + 1, 6)
340
+ table_shape = slide_peers.shapes.add_table(
341
+ rows, cols, Inches(0.8), Inches(1.6), Inches(11.7), Inches(3.2)
342
+ )
343
+ table = table_shape.table
344
+
345
+ # Headers
346
+ headers = ["Company", "Revenue Growth", "Operating Margin", "P/E Ratio", "ROE"]
347
+ for c_idx, h in enumerate(headers):
348
+ _style_table_cell(table.cell(0, c_idx), h, FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE, PP_ALIGN.LEFT if c_idx == 0 else PP_ALIGN.CENTER)
349
+
350
+ for r_idx in range(rows - 1):
351
+ peer = peers_list[r_idx]
352
+ curr_row = r_idx + 1
353
+ bg_color = COLOR_LIGHT_GRAY if curr_row % 2 == 1 else COLOR_LIGHT_BG
354
+
355
+ # Format peer outputs
356
+ rev_gr = peer.get("revenue_growth", "N/A")
357
+ if isinstance(rev_gr, float): rev_gr = f"{rev_gr*100:.1f}%"
358
+ op_marg = peer.get("operating_margin", "N/A")
359
+ if isinstance(op_marg, float): op_marg = f"{op_marg*100:.1f}%"
360
+ pe_r = peer.get("pe_ratio", "N/A")
361
+ if isinstance(pe_r, float): pe_r = f"{pe_r:.1f}x"
362
+ roe_val = peer.get("roe", "N/A")
363
+ if isinstance(roe_val, float): roe_val = f"{roe_val*100:.1f}%"
364
+
365
+ _style_table_cell(table.cell(curr_row, 0), peer.get("name", "N/A"), FONT_MAJOR, 11, False, COLOR_DARK_TEXT, bg_color, PP_ALIGN.LEFT)
366
+ _style_table_cell(table.cell(curr_row, 1), str(rev_gr), FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color)
367
+ _style_table_cell(table.cell(curr_row, 2), str(op_marg), FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color)
368
+ _style_table_cell(table.cell(curr_row, 3), str(pe_r), FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color)
369
+ _style_table_cell(table.cell(curr_row, 4), str(roe_val), FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color)
370
+
371
+ # Peer comparison commentary
372
+ peer_comm = data.get("peer_comparison_commentary", "")
373
+ if peer_comm:
374
+ comment_box = slide_peers.shapes.add_textbox(Inches(0.8), Inches(5.0), Inches(11.7), Inches(1.6))
375
+ tf_c = comment_box.text_frame
376
+ tf_c.word_wrap = True
377
+ p_c = tf_c.paragraphs[0]
378
+ p_c.text = "Benchmarking Insights: " + (peer_comm[:380] + "..." if len(peer_comm) > 380 else peer_comm)
379
+ p_c.font.name = FONT_MINOR
380
+ p_c.font.size = Pt(12)
381
+ p_c.font.color.rgb = COLOR_DARK_TEXT
382
+ p_c.line_spacing = 1.2
383
+
384
+ # ==========================================
385
+ # SLIDE 6: Key Synthesis Insights
386
+ # ==========================================
387
+ insights_list = data.get("key_insights", [])
388
+ if insights_list:
389
+ slide_insights = prs.slides.add_slide(blank_layout)
390
+ _apply_theme_decorations(slide_insights, prs)
391
+ _add_slide_title(slide_insights, "Synthesized Key Insights")
392
+
393
+ # Clean insights
394
+ clean_insights = []
395
+ for ins in insights_list:
396
+ if isinstance(ins, dict):
397
+ clean_insights.append(f"{ins.get('title', '')}: {ins.get('text', '')}")
398
+ else:
399
+ clean_insights.append(str(ins))
400
+
401
+ _add_bullet_points(slide_insights, clean_insights[:4])
402
+
403
+ # ==========================================
404
+ # SLIDE 7: Risks & Red Flags
405
+ # ==========================================
406
+ risks_list = data.get("risks", [])
407
+ if risks_list:
408
+ slide_risks = prs.slides.add_slide(blank_layout)
409
+ _apply_theme_decorations(slide_risks, prs)
410
+ _add_slide_title(slide_risks, "Investment Risks & Key Red Flags")
411
+
412
+ clean_risks = []
413
+ for r in risks_list:
414
+ if isinstance(r, dict):
415
+ clean_risks.append(f"{r.get('title', '')} ({r.get('impact', 'Medium Impact')}): {r.get('text', '')}")
416
+ else:
417
+ clean_risks.append(str(r))
418
+
419
+ _add_bullet_points(slide_risks, clean_risks[:4])
420
+
421
+ # Save Presentation
422
+ os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
423
+ prs.save(output_path)
config.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ config.py — App-wide constants and environment loading.
3
+ """
4
+ import os
5
+ from pathlib import Path
6
+ from dotenv import load_dotenv
7
+
8
+ # Load .env from the app root
9
+ _ENV_PATH = Path(__file__).parent / ".env"
10
+ load_dotenv(_ENV_PATH)
11
+
12
+ # ── API Keys ──────────────────────────────────────────────────────────────────
13
+ FMP_API_KEY: str = os.environ.get("FMP_API_KEY", "")
14
+ GEMINI_API_KEY: str = os.environ.get("GEMINI_API_KEY", "")
15
+ CLAUDE_API_KEY: str = os.environ.get("CLAUDE_API_KEY", "")
16
+ OPENAI_API_KEY: str = os.environ.get("OPENAI_API_KEY", "")
17
+ GROQ_API_KEY: str = os.environ.get("GROQ_API_KEY", "")
18
+
19
+ # ── Data settings ─────────────────────────────────────────────────────────────
20
+ DEFAULT_YEARS: int = 5
21
+ CACHE_TTL_DAYS: int = 7 # days before cached data expires
22
+ FMP_BASE: str = "https://financialmodelingprep.com/api"
23
+ SCREENER_BASE: str = "https://screener.in/company"
24
+ REQUEST_TIMEOUT: int = 20
25
+
26
+ # ── Output ────────────────────────────────────────────────────────────────────
27
+ OUTPUT_DIR: Path = Path(__file__).parent / "outputs"
28
+ OUTPUT_DIR.mkdir(exist_ok=True)
29
+
30
+ DB_PATH: Path = Path(__file__).parent / "storage" / "history.db"
31
+ DB_PATH.parent.mkdir(exist_ok=True)
32
+
33
+ # ── AI Models ─────────────────────────────────────────────────────────────────
34
+ GEMINI_MODEL: str = "gemini-2.0-flash"
35
+ CLAUDE_MODEL: str = "claude-3-5-haiku-20241022"
36
+ OPENAI_MODEL: str = "gpt-4o-mini"
37
+ GROQ_MODEL: str = "llama-3.3-70b-versatile" # free, high-quality
38
+
39
+ # ── App metadata ──────────────────────────────────────────────────────────────
40
+ APP_NAME: str = "Financial Analyzer"
41
+ APP_VERSION: str = "1.0.0"
main.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ main.py — Financial Analyzer entry point.
4
+
5
+ Run: python main.py
6
+ """
7
+ import sys, io, os
8
+ from pathlib import Path
9
+
10
+ # Force UTF-8 on Windows so log messages with unicode chars (→ ✓ ✗) don't crash
11
+ if sys.platform == "win32":
12
+ os.environ["PYTHONIOENCODING"] = "utf-8"
13
+ try:
14
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
15
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
16
+ except AttributeError:
17
+ pass
18
+
19
+ # Make sure local packages resolve correctly
20
+ sys.path.insert(0, str(Path(__file__).parent))
21
+
22
+ from storage.cache import init_db
23
+
24
+ def main():
25
+ # Ensure DB exists
26
+ init_db()
27
+
28
+ try:
29
+ from gui.app_window import AppWindow
30
+ except ImportError as e:
31
+ print(f"ERROR: Could not import GUI: {e}")
32
+ print("Make sure you have installed dependencies:")
33
+ print(" pip install customtkinter Pillow requests beautifulsoup4 lxml")
34
+ print(" pip install python-dotenv google-generativeai anthropic openpyxl python-docx pandas")
35
+ sys.exit(1)
36
+
37
+ app = AppWindow()
38
+ app.mainloop()
39
+
40
+
41
+ if __name__ == "__main__":
42
+ main()
pipeline/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # pipeline package
pipeline/analyst.py ADDED
@@ -0,0 +1,579 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline/analyst.py — Multi-model AI narrative generation.
3
+
4
+ Supports Google Gemini and Anthropic Claude.
5
+ Each model generates the full narrative; if both selected,
6
+ outputs are combined with a comparison section.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional
14
+
15
+ sys.path.insert(0, str(Path(__file__).parent.parent))
16
+ from config import GEMINI_API_KEY, CLAUDE_API_KEY, GEMINI_MODEL, CLAUDE_MODEL, OPENAI_API_KEY, OPENAI_MODEL, GROQ_API_KEY, GROQ_MODEL
17
+
18
+
19
+ # ── Formatting helpers ────────────────────────────────────────────────────────
20
+
21
+ def _pct(v):
22
+ return f"{v*100:.1f}%" if v is not None else "N/A"
23
+
24
+ def _num(v, dec=2):
25
+ return f"{v:.{dec}f}" if v is not None else "N/A"
26
+
27
+ def _money(v, currency="INR", units="Crores"):
28
+ """Format a pre-scaled monetary value. Data is already in Crores (INR) or Millions (USD)."""
29
+ if v is None:
30
+ return "N/A"
31
+ sym = "\u20b9" if currency == "INR" else "$"
32
+ av = abs(v)
33
+ if currency == "INR":
34
+ # Data already in Crores
35
+ if av >= 100000:
36
+ return f"{sym}{v/100000:,.2f} L.Cr"
37
+ return f"{sym}{v:,.2f} Cr"
38
+ else:
39
+ # Data already in Millions
40
+ if av >= 1000:
41
+ return f"{sym}{v/1000:,.2f}B"
42
+ return f"{sym}{v:,.2f}M"
43
+
44
+
45
+ # ── Context builder ───────────────────────────────────────────────────────────
46
+
47
+ def _build_context(data: Dict, meta: Dict) -> str:
48
+ """Build a rich textual context string from all financial data."""
49
+ years = data.get("years", [])
50
+ inc = data.get("income_statement", {})
51
+ bal = data.get("balance_sheet", {})
52
+ cf = data.get("cash_flow", {})
53
+ rat = data.get("ratios", {})
54
+ gr = data.get("growth_metrics", {})
55
+ dp = data.get("dupont", {})
56
+ cur = data.get("currency", "INR")
57
+
58
+ def row(label, arr, fmt="money"):
59
+ units = data.get("units", "Crores")
60
+ vals = " | ".join([
61
+ (_money(v, cur, units) if fmt == "money" else _pct(v) if fmt == "pct" else _num(v))
62
+ for v in (arr or [])
63
+ ])
64
+ return f" {label}: {vals}"
65
+
66
+ lines = [
67
+ f"COMPANY: {meta.get('name', '')} ({meta.get('ticker', '')})",
68
+ f"EXCHANGE: {meta.get('exchange', '')} | SECTOR: {meta.get('sector', '')} | INDUSTRY: {meta.get('industry', '')}",
69
+ f"COUNTRY: {meta.get('country', '')} | CURRENCY: {cur}",
70
+ f"YEARS (oldest→newest): {' | '.join(str(y) for y in years)}",
71
+ "",
72
+ "=== INCOME STATEMENT ===",
73
+ row("Revenue", inc.get("revenue")),
74
+ row("Gross Profit", inc.get("gross_profit")),
75
+ row("Operating Income", inc.get("operating_income")),
76
+ row("EBITDA", inc.get("ebitda")),
77
+ row("Net Income", inc.get("net_income")),
78
+ row("Diluted EPS", inc.get("diluted_eps"), "num"),
79
+ "",
80
+ "=== BALANCE SHEET ===",
81
+ row("Total Assets", bal.get("total_assets")),
82
+ row("Total Equity", bal.get("total_equity")),
83
+ row("Total Liabilities", bal.get("total_liabilities")),
84
+ row("Long-term Debt", bal.get("long_term_debt")),
85
+ row("Cash", bal.get("cash_and_equivalents")),
86
+ row("Current Assets", bal.get("total_current_assets")),
87
+ row("Current Liabilities",bal.get("total_current_liabilities")),
88
+ "",
89
+ "=== CASH FLOW ===",
90
+ row("Cash from Operations", cf.get("cash_from_operations")),
91
+ row("Capex", cf.get("capex")),
92
+ row("Free Cash Flow", cf.get("free_cash_flow")),
93
+ "",
94
+ "=== PROFITABILITY RATIOS ===",
95
+ row("Gross Margin", rat.get("gross_margin"), "pct"),
96
+ row("Operating Margin", rat.get("operating_margin"), "pct"),
97
+ row("Net Margin", rat.get("net_margin"), "pct"),
98
+ row("ROE", rat.get("roe"), "pct"),
99
+ row("ROA", rat.get("roa"), "pct"),
100
+ row("ROIC", rat.get("roic"), "pct"),
101
+ "",
102
+ "=== LIQUIDITY & SOLVENCY ===",
103
+ row("Current Ratio", rat.get("current_ratio"), "num"),
104
+ row("Quick Ratio", rat.get("quick_ratio"), "num"),
105
+ row("Debt-to-Equity", rat.get("debt_to_equity"), "num"),
106
+ row("Net Debt/EBITDA", rat.get("net_debt_to_ebitda"), "num"),
107
+ row("Interest Coverage", rat.get("interest_coverage"), "num"),
108
+ "",
109
+ "=== EFFICIENCY ===",
110
+ row("Asset Turnover", rat.get("asset_turnover"), "num"),
111
+ row("DSO (days)", rat.get("days_sales_outstanding"), "num"),
112
+ row("DPO (days)", rat.get("days_payables"), "num"),
113
+ row("Cash Conversion Cycle",rat.get("cash_conversion_cycle"), "num"),
114
+ "",
115
+ "=== DUPONT DECOMPOSITION ===",
116
+ row("Net Margin", dp.get("net_margin"), "pct"),
117
+ row("Asset Turnover", dp.get("asset_turnover"), "num"),
118
+ row("Equity Multiplier", dp.get("equity_multiplier"), "num"),
119
+ row("ROE (DuPont)", dp.get("roe_computed"), "pct"),
120
+ "",
121
+ "=== GROWTH METRICS ===",
122
+ row("Revenue YoY", gr.get("revenue_yoy"), "pct"),
123
+ f" Revenue 5yr CAGR: {_pct(gr.get('revenue_cagr_5y'))}",
124
+ row("Net Income YoY", gr.get("net_income_yoy"), "pct"),
125
+ f" Net Income 5yr CAGR: {_pct(gr.get('net_income_cagr_5y'))}",
126
+ row("FCF YoY", gr.get("fcf_yoy"), "pct"),
127
+ ]
128
+
129
+ # Valuation
130
+ val = data.get("valuation_snapshot", {})
131
+ if val:
132
+ lines += [
133
+ "",
134
+ "=== VALUATION SNAPSHOT ===",
135
+ f" P/E (TTM): {_num(val.get('pe_ttm'))}",
136
+ f" P/B: {_num(val.get('pb'))}",
137
+ f" EV/EBITDA: {_num(val.get('ev_ebitda'))}",
138
+ f" FCF Yield: {_pct(val.get('fcf_yield'))}",
139
+ f" Dividend Yield: {_pct(val.get('dividend_yield'))}",
140
+ ]
141
+
142
+ # Peers
143
+ peers = data.get("peers", [])
144
+ if peers:
145
+ lines += ["", "=== PEER BENCHMARKING ==="]
146
+ for p in peers:
147
+ lines.append(
148
+ f" {p.get('name')} ({p.get('ticker')}): "
149
+ f"Rev={_money(p.get('revenue'), p.get('currency','USD'))} | "
150
+ f"OpM={_pct(p.get('operating_margin'))} | "
151
+ f"ROE={_pct(p.get('roe'))} | "
152
+ f"D/E={_num(p.get('debt_to_equity'))}"
153
+ )
154
+
155
+ return "\n".join(lines)
156
+
157
+
158
+ # ── System prompt ─────────────────────────────────────────────────────────────
159
+
160
+ SYSTEM_PROMPT = """You are a senior financial analyst with expertise in Indian and global equity markets.
161
+ You write rigorous, opinionated, data-driven financial analysis reports.
162
+
163
+ Guidelines:
164
+ - Be specific: cite exact numbers from the data provided, not generic statements
165
+ - Be opinionated: state what the numbers actually mean, not just what they are
166
+ - Use Indian financial terminology where appropriate (Crores, Lakhs, SEBI, NSE/BSE)
167
+ - Flag data gaps honestly rather than speculating
168
+ - Keep commentary professional but readable
169
+ - Do NOT use markdown formatting in your response — plain prose only
170
+ - Paragraph breaks only (use double newline to separate paragraphs)
171
+ - Numbers should be expressed in appropriate units (Cr for large INR amounts, B/M for USD)
172
+ """
173
+
174
+ # ── Section prompts ───────────────────────────────────────────────────────────
175
+
176
+ SECTION_PROMPTS = {
177
+ "executive_summary": """
178
+ Write a 250-300 word executive summary of this company's financial health.
179
+ Answer these questions directly:
180
+ 1. Is the business financially healthy or deteriorating?
181
+ 2. What is the most important trend in the numbers?
182
+ 3. How does it stack up against peers?
183
+ 4. What is the key risk visible in the data?
184
+ 5. What is the key opportunity?
185
+ Be direct and opinionated. No hedging.
186
+ """,
187
+ "company_profile": """
188
+ Write a 2-paragraph company profile covering:
189
+ - What the company does, its key business segments, and geographic presence
190
+ - Its position in the market, key products/services, major customers or clients if known
191
+ Use public knowledge about this company; supplement with the financial data provided.
192
+ """,
193
+ "industry_profile": """
194
+ Write a 2-paragraph industry profile for this company's sector/industry covering:
195
+ - Market size and structure (fragmented/consolidated, key players)
196
+ - Key growth drivers and headwinds for this industry in the Indian/global context
197
+ - Regulatory environment relevant to this industry
198
+ """,
199
+ "swot": """
200
+ Perform a rigorous SWOT analysis grounded in the financial data and industry knowledge.
201
+ Format your response as four sections:
202
+
203
+ STRENGTHS:
204
+ [3-4 specific, data-grounded strengths]
205
+
206
+ WEAKNESSES:
207
+ [3-4 specific, data-grounded weaknesses]
208
+
209
+ OPPORTUNITIES:
210
+ [3-4 specific opportunities for this company/industry]
211
+
212
+ THREATS:
213
+ [3-4 specific threats, including competitive and regulatory]
214
+
215
+ Be specific — avoid generic SWOT points that could apply to any company.
216
+ """,
217
+ "industry_trends": """
218
+ Write 2-3 paragraphs on current and emerging trends in this company's industry, covering:
219
+ - Macro trends (digitization, ESG, AI, PLI schemes for Indian companies, etc.)
220
+ - Competitive dynamics and consolidation
221
+ - Technology disruption if applicable
222
+ - Regulatory changes (SEBI, RBI, sector-specific regulators for Indian companies)
223
+ Ground trends in the financial data where possible (e.g., if margins are compressing, is it industry-wide?).
224
+ """,
225
+ "trend_analysis_revenue": """
226
+ Write 2-3 paragraphs analyzing the revenue and profitability trends over the analysis period.
227
+ Specifically address:
228
+ - Revenue trajectory (acceleration/deceleration/volatility)
229
+ - Gross margin trend and what it reveals about pricing power
230
+ - Operating leverage (are operating margins improving as revenue grows?)
231
+ - Quality of earnings (is net income growing in line with revenue?)
232
+ Reference specific numbers from the data.
233
+ """,
234
+ "trend_analysis_balance_sheet": """
235
+ Write 2 paragraphs analyzing balance sheet strength and capital structure trends.
236
+ Address:
237
+ - Liquidity position (current ratio, cash levels)
238
+ - Leverage trajectory (is debt rising or falling relative to equity/earnings?)
239
+ - Asset quality (goodwill, receivables trends)
240
+ - Working capital efficiency
241
+ """,
242
+ "trend_analysis_cashflow": """
243
+ Write 2 paragraphs analyzing cash flow quality and capital allocation.
244
+ Address:
245
+ - OCF quality (does it consistently exceed net income, or lag it — and why?)
246
+ - Capex intensity and investment cycle
247
+ - Free cash flow generation and its trajectory
248
+ - How is FCF being allocated (buybacks, dividends, debt repayment, acquisitions)?
249
+ """,
250
+ "ratio_commentary": """
251
+ Write 2-3 paragraphs interpreting the ratio analysis.
252
+ Focus on:
253
+ - Which ratios show the most significant trends (positive or negative)?
254
+ - Any ratios that are outliers vs. industry norms?
255
+ - What do the efficiency ratios (DSO, DPO, CCC) tell us about operational quality?
256
+ - DuPont decomposition: which driver is most responsible for ROE trends?
257
+ """,
258
+ "peer_commentary": """
259
+ Write 2 paragraphs benchmarking this company against its peers.
260
+ Address:
261
+ - Where does the company rank on the most important metrics for this industry?
262
+ - What explains the gaps vs. peers (structural, cyclical, or management decisions)?
263
+ - Is the company gaining or losing competitive position?
264
+ """,
265
+ "key_insights": """
266
+ List 4-6 key insights, one per line. Each insight must:
267
+ - Be substantive and specific (not generic)
268
+ - Reference a specific number or trend from the data
269
+ - Convey an analytical judgment, not just a fact
270
+
271
+ Format: One insight per line, starting with a dash (-).
272
+ """,
273
+ "risks": """
274
+ List the top 5-7 risks facing this company. For each risk, use this format:
275
+ CATEGORY | Risk description (specific, grounded in data or industry knowledge)
276
+
277
+ Categories to use: Financial, Business, Industry, Regulatory, ESG
278
+ """,
279
+ "things_to_watch": """
280
+ List 4-5 specific, monitorable metrics or events that would change the investment picture.
281
+ Format: One per line starting with a dash (-).
282
+ Be concrete: e.g., 'Watch for gross margin recovery above 30% in FY26' not 'watch margins'.
283
+ """,
284
+ "limitations": """
285
+ List any material analytical limitations in 3-5 bullet points. Include:
286
+ - Data gaps (which line items are missing or estimated)
287
+ - Comparability issues (fiscal year changes, acquisitions)
288
+ - Data source caveats (scraped vs. filed data)
289
+ - Any ratios that could not be computed meaningfully (negative equity, etc.)
290
+ Format: One per line starting with a dash (-).
291
+ """,
292
+ }
293
+
294
+
295
+ # ── Gemini ────────────────────────────────────────────────────────────────────
296
+
297
+ def _gemini_generate(context: str, section: str, log_fn=None) -> str:
298
+ """Generate using Gemini — tries API key first, then Google OAuth login."""
299
+ prompt = f"{SYSTEM_PROMPT}\n\nFINANCIAL DATA:\n{context}\n\nTASK:\n{SECTION_PROMPTS[section]}"
300
+
301
+ # ── Path 1: API Key (new google-genai SDK) ──────────────────────────────
302
+ if GEMINI_API_KEY:
303
+ try:
304
+ from google import genai
305
+ from google.genai import types as gtypes
306
+ client = genai.Client(api_key=GEMINI_API_KEY)
307
+ response = client.models.generate_content(
308
+ model=GEMINI_MODEL,
309
+ contents=prompt,
310
+ config=gtypes.GenerateContentConfig(temperature=0.2, max_output_tokens=1024),
311
+ )
312
+ return response.text.strip()
313
+ except Exception:
314
+ pass
315
+ # Fallback to old SDK
316
+ try:
317
+ import google.generativeai as genai_old
318
+ genai_old.configure(api_key=GEMINI_API_KEY)
319
+ model = genai_old.GenerativeModel(model_name=GEMINI_MODEL, system_instruction=SYSTEM_PROMPT)
320
+ response = model.generate_content(f"FINANCIAL DATA:\n{context}\n\nTASK:\n{SECTION_PROMPTS[section]}")
321
+ return response.text.strip()
322
+ except Exception as e2:
323
+ return f"[Gemini error for {section}: {e2}]"
324
+
325
+ return f"[Gemini unavailable for {section}: API key missing]"
326
+
327
+
328
+
329
+ # ── OpenAI / ChatGPT ─────────────────────────────────────────────────────────
330
+
331
+ def _openai_generate(context: str, section: str, log_fn=None) -> str:
332
+ try:
333
+ from openai import OpenAI
334
+ client = OpenAI(api_key=OPENAI_API_KEY)
335
+ prompt = f"FINANCIAL DATA:\n{context}\n\nTASK:\n{SECTION_PROMPTS[section]}"
336
+ resp = client.chat.completions.create(
337
+ model=OPENAI_MODEL,
338
+ messages=[
339
+ {"role": "system", "content": SYSTEM_PROMPT},
340
+ {"role": "user", "content": prompt},
341
+ ],
342
+ temperature=0.2,
343
+ max_tokens=1024,
344
+ )
345
+ return resp.choices[0].message.content.strip()
346
+ except Exception as e:
347
+ return f"[ChatGPT error for {section}: {e}]"
348
+
349
+
350
+ # ── Groq (free — Llama 3.3 70B) ──────────────────────────────────────────────
351
+
352
+ def _groq_generate(context: str, section: str, log_fn=None) -> str:
353
+ """Generate using Groq's OpenAI-compatible API (free tier, Llama 3.3 70B)."""
354
+ try:
355
+ from openai import OpenAI
356
+ client = OpenAI(
357
+ api_key=GROQ_API_KEY,
358
+ base_url="https://api.groq.com/openai/v1",
359
+ )
360
+ prompt = f"FINANCIAL DATA:\n{context}\n\nTASK:\n{SECTION_PROMPTS[section]}"
361
+ resp = client.chat.completions.create(
362
+ model=GROQ_MODEL,
363
+ messages=[
364
+ {"role": "system", "content": SYSTEM_PROMPT},
365
+ {"role": "user", "content": prompt},
366
+ ],
367
+ temperature=0.2,
368
+ max_tokens=1024,
369
+ )
370
+ return resp.choices[0].message.content.strip()
371
+ except Exception as e:
372
+ return f"[Groq error for {section}: {e}]"
373
+
374
+
375
+ # ── Claude ────────────────────────────────────────────────────────────────────
376
+
377
+ def _claude_generate(context: str, section: str, log_fn=None) -> str:
378
+ try:
379
+ import anthropic
380
+ client = anthropic.Anthropic(api_key=CLAUDE_API_KEY)
381
+ prompt = f"FINANCIAL DATA:\n{context}\n\nTASK:\n{SECTION_PROMPTS[section]}"
382
+ message = client.messages.create(
383
+ model=CLAUDE_MODEL,
384
+ max_tokens=1024,
385
+ system=SYSTEM_PROMPT,
386
+ messages=[{"role": "user", "content": prompt}],
387
+ )
388
+ return message.content[0].text.strip()
389
+ except Exception as e:
390
+ return f"[Claude error for {section}: {e}]"
391
+
392
+
393
+ # ── Parse structured sections ─────────────────────────────────────────────────
394
+
395
+ def _parse_swot(text: str) -> Dict[str, List[str]]:
396
+ """Parse SWOT text into a structured dict."""
397
+ result = {"strengths": [], "weaknesses": [], "opportunities": [], "threats": []}
398
+ current = None
399
+ for line in text.split("\n"):
400
+ l = line.strip()
401
+ if l.upper().startswith("STRENGTH"):
402
+ current = "strengths"
403
+ elif l.upper().startswith("WEAKNESS"):
404
+ current = "weaknesses"
405
+ elif l.upper().startswith("OPPORTUNIT"):
406
+ current = "opportunities"
407
+ elif l.upper().startswith("THREAT"):
408
+ current = "threats"
409
+ elif current and l and not l.upper() in ("STRENGTHS:", "WEAKNESSES:", "OPPORTUNITIES:", "THREATS:"):
410
+ result[current].append(l.lstrip("-• "))
411
+ return result
412
+
413
+
414
+ def _parse_bullets(text: str) -> List[str]:
415
+ """Parse a bulleted list response into a list of strings."""
416
+ items = []
417
+ for line in text.split("\n"):
418
+ l = line.strip().lstrip("-•* ")
419
+ if l:
420
+ items.append(l)
421
+ return items
422
+
423
+
424
+ def _parse_risks(text: str) -> List[Dict]:
425
+ """Parse risk list into categorized dicts."""
426
+ risks = []
427
+ for line in text.split("\n"):
428
+ l = line.strip().lstrip("-•* ")
429
+ if "|" in l:
430
+ parts = l.split("|", 1)
431
+ risks.append({"category": parts[0].strip(), "description": parts[1].strip()})
432
+ elif l:
433
+ risks.append({"category": "General", "description": l})
434
+ return risks
435
+
436
+
437
+ # ── Main entry point ──────────────────────────────────────────────────────────
438
+
439
+ def generate_narrative(
440
+ data: Dict,
441
+ meta: Dict,
442
+ models: List[str],
443
+ log_fn=None,
444
+ ) -> Dict:
445
+ """
446
+ Generate full narrative analysis using selected AI models.
447
+
448
+ Args:
449
+ data: Calculated financial data dict.
450
+ meta: CompanyMeta.to_dict()
451
+ models: List of model names, e.g. ['gemini', 'claude'] or ['gemini']
452
+ log_fn: Optional progress callback.
453
+
454
+ Returns:
455
+ Dict with all narrative fields populated.
456
+ """
457
+ def log(msg):
458
+ if log_fn:
459
+ log_fn(msg)
460
+
461
+ use_gemini = "gemini" in models and bool(GEMINI_API_KEY)
462
+ use_claude = "claude" in models and bool(CLAUDE_API_KEY)
463
+ use_openai = "openai" in models and bool(OPENAI_API_KEY)
464
+ use_groq = "groq" in models and bool(GROQ_API_KEY)
465
+
466
+ if not use_gemini and not use_claude and not use_openai and not use_groq:
467
+ log("No AI models configured. Running data-only analysis (no narrative).")
468
+ return _empty_narrative()
469
+
470
+ context = _build_context(data, meta)
471
+ active = [m for m, ok in [("Gemini",use_gemini),("Claude",use_claude),("ChatGPT",use_openai),("Groq (Llama)",use_groq)] if ok]
472
+ log(f"Generating AI narrative ({', '.join(active)}) ...")
473
+
474
+ def gen(section: str) -> str:
475
+ results = []
476
+ if use_gemini:
477
+ log(f" Gemini: {section} ...")
478
+ results.append(("Gemini", _gemini_generate(context, section, log_fn)))
479
+ if use_claude:
480
+ log(f" Claude: {section} ...")
481
+ results.append(("Claude", _claude_generate(context, section, log_fn)))
482
+ if use_openai:
483
+ log(f" ChatGPT: {section} ...")
484
+ results.append(("ChatGPT", _openai_generate(context, section, log_fn)))
485
+ if use_groq:
486
+ log(f" Groq (Llama 3.3): {section} ...")
487
+ results.append(("Groq/Llama", _groq_generate(context, section, log_fn)))
488
+
489
+ if len(results) == 1:
490
+ return results[0][1]
491
+ # Multiple models — combine with attribution
492
+ combined = ""
493
+ for name, text in results:
494
+ combined += f"[{name}]\n{text}\n\n"
495
+ return combined.strip()
496
+
497
+ # Generate all sections
498
+ log(" Generating executive summary …")
499
+ exec_summary = gen("executive_summary")
500
+
501
+ log(" Generating company profile …")
502
+ company_overview = gen("company_profile")
503
+
504
+ log(" Generating industry profile …")
505
+ industry_profile = gen("industry_profile")
506
+
507
+ log(" Generating SWOT …")
508
+ swot_raw = gen("swot")
509
+ swot = _parse_swot(swot_raw)
510
+
511
+ log(" Generating industry trends …")
512
+ industry_trends = gen("industry_trends")
513
+
514
+ log(" Generating trend analysis …")
515
+ trend_revenue = gen("trend_analysis_revenue")
516
+ trend_bs = gen("trend_analysis_balance_sheet")
517
+ trend_cf = gen("trend_analysis_cashflow")
518
+
519
+ log(" Generating ratio commentary …")
520
+ ratio_commentary = gen("ratio_commentary")
521
+
522
+ log(" Generating peer commentary …")
523
+ peer_commentary = gen("peer_commentary")
524
+
525
+ log(" Generating insights …")
526
+ insights_raw = gen("key_insights")
527
+ insights = _parse_bullets(insights_raw)
528
+
529
+ log(" Generating risks …")
530
+ risks_raw = gen("risks")
531
+ risks = _parse_risks(risks_raw)
532
+
533
+ log(" Generating things to watch …")
534
+ watch_raw = gen("things_to_watch")
535
+ things_to_watch = _parse_bullets(watch_raw)
536
+
537
+ log(" Generating limitations …")
538
+ lim_raw = gen("limitations")
539
+ limitations = _parse_bullets(lim_raw)
540
+
541
+ log(" ✓ AI narrative generation complete")
542
+
543
+ return {
544
+ "executive_summary": exec_summary,
545
+ "company_overview": company_overview,
546
+ "industry_profile": industry_profile,
547
+ "swot": swot,
548
+ "industry_trends": industry_trends,
549
+ "trend_analysis": {
550
+ "revenue_and_profitability": trend_revenue,
551
+ "balance_sheet_strength": trend_bs,
552
+ "cash_flow_generation": trend_cf,
553
+ },
554
+ "ratio_analysis_commentary": ratio_commentary,
555
+ "peer_comparison_commentary": peer_commentary,
556
+ "key_insights": insights,
557
+ "risks": risks,
558
+ "things_to_watch": things_to_watch,
559
+ "limitations_and_caveats": limitations,
560
+ "models_used": models,
561
+ }
562
+
563
+
564
+ def _empty_narrative() -> Dict:
565
+ return {
566
+ "executive_summary": "AI narrative not generated (no API keys configured).",
567
+ "company_overview": "",
568
+ "industry_profile": "",
569
+ "swot": {"strengths": [], "weaknesses": [], "opportunities": [], "threats": []},
570
+ "industry_trends": "",
571
+ "trend_analysis": {"revenue_and_profitability": "", "balance_sheet_strength": "", "cash_flow_generation": ""},
572
+ "ratio_analysis_commentary": "",
573
+ "peer_comparison_commentary":"",
574
+ "key_insights": [],
575
+ "risks": [],
576
+ "things_to_watch": [],
577
+ "limitations_and_caveats": [],
578
+ "models_used": [],
579
+ }
pipeline/calculator.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline/calculator.py — Compute all financial ratios, DuPont decomposition,
3
+ growth metrics, and scale numbers for display.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import math
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+
10
+
11
+ def _safe_div(num, den, default=None):
12
+ """Safe division returning default if denominator is None/zero."""
13
+ try:
14
+ if num is None or den is None:
15
+ return default
16
+ if den == 0:
17
+ return default
18
+ return num / den
19
+ except (TypeError, ZeroDivisionError):
20
+ return default
21
+
22
+
23
+ def _avg(a, b):
24
+ """Average of two values, None if either is None."""
25
+ if a is None or b is None:
26
+ return None
27
+ return (a + b) / 2
28
+
29
+
30
+ def _pct_change(current, previous):
31
+ """Year-over-year % change."""
32
+ if current is None or previous is None or previous == 0:
33
+ return None
34
+ return (current - previous) / abs(previous)
35
+
36
+
37
+ def _cagr(start, end, n):
38
+ """Compound annual growth rate over n periods."""
39
+ if start is None or end is None or n <= 0:
40
+ return None
41
+ if start <= 0 or end < 0:
42
+ return None
43
+ try:
44
+ return (end / start) ** (1 / n) - 1
45
+ except (ZeroDivisionError, ValueError):
46
+ return None
47
+
48
+
49
+ def _series(data_dict: Dict, key: str, n: int) -> List[Optional[float]]:
50
+ """Extract a time-series list, padded/trimmed to length n."""
51
+ vals = data_dict.get(key, [])
52
+ if len(vals) < n:
53
+ vals = vals + [None] * (n - len(vals))
54
+ return vals[:n]
55
+
56
+
57
+ # ── Auto unit scaling ─────────────────────────────────────────────────────────
58
+
59
+ def auto_scale(values: List[Optional[float]], currency: str) -> Tuple[List[Optional[float]], str]:
60
+ """
61
+ Scale a list of monetary values to a human-readable unit.
62
+ Returns (scaled_values, unit_label).
63
+ """
64
+ non_null = [abs(v) for v in values if v is not None and v != 0]
65
+ if not non_null:
66
+ return values, ""
67
+ mag = max(non_null)
68
+
69
+ if currency == "INR":
70
+ if mag >= 1e7: # >= 1 Crore (in thousands → multiply)
71
+ # FMP stores in thousands for Indian, screener in Crores
72
+ # We'll just label appropriately
73
+ if mag >= 1e9:
74
+ factor, label = 1e7, "₹ Crores"
75
+ elif mag >= 1e5:
76
+ factor, label = 1e5, "₹ Lakhs"
77
+ else:
78
+ factor, label = 1e3, "₹ Thousands"
79
+ else:
80
+ factor, label = 1, "₹"
81
+ # Most FMP returns in actual currency value (not thousands)
82
+ # screener returns in Crores already
83
+ return values, label
84
+ else:
85
+ # USD / other
86
+ if mag >= 1e9:
87
+ factor, label = 1e9, "Billions"
88
+ elif mag >= 1e6:
89
+ factor, label = 1e6, "Millions"
90
+ elif mag >= 1e3:
91
+ factor, label = 1e3, "Thousands"
92
+ else:
93
+ factor, label = 1, ""
94
+ scaled = [v / factor if v is not None else None for v in values]
95
+ return scaled, label
96
+
97
+
98
+ def smart_format(value: Optional[float], currency: str = "USD") -> str:
99
+ """Format a single monetary value with smart units."""
100
+ if value is None:
101
+ return "N/A"
102
+ av = abs(value)
103
+ if currency == "INR":
104
+ if av >= 1e7:
105
+ return f"₹{value/1e7:,.2f} Cr"
106
+ elif av >= 1e5:
107
+ return f"₹{value/1e5:,.2f} L"
108
+ else:
109
+ return f"₹{value:,.0f}"
110
+ else:
111
+ sym = "$" if currency == "USD" else ""
112
+ if av >= 1e9:
113
+ return f"{sym}{value/1e9:,.2f}B"
114
+ elif av >= 1e6:
115
+ return f"{sym}{value/1e6:,.2f}M"
116
+ elif av >= 1e3:
117
+ return f"{sym}{value/1e3:,.2f}K"
118
+ else:
119
+ return f"{sym}{value:,.2f}"
120
+
121
+
122
+ # ── Main calculation entry ────────────────────────────────────────────────────
123
+
124
+ def calculate(financial_data: Dict, meta_dict: Dict) -> Dict:
125
+ """
126
+ Compute all ratios, DuPont, growth metrics from raw financial data.
127
+
128
+ Args:
129
+ financial_data: Output of fetcher.fetch_all()
130
+ meta_dict: CompanyMeta.to_dict()
131
+
132
+ Returns:
133
+ Complete analysis dict ready for analyst.py and builders.
134
+ """
135
+ years = financial_data.get("years", [])
136
+ n = len(years)
137
+ if n == 0:
138
+ return financial_data # Nothing to compute
139
+
140
+ inc = financial_data.get("income_statement", {})
141
+ bal = financial_data.get("balance_sheet", {})
142
+ cf = financial_data.get("cash_flow", {})
143
+ currency = financial_data.get("currency", meta_dict.get("currency", "USD"))
144
+
145
+ def S(key, src=None):
146
+ src = src or inc
147
+ return _series(src, key, n)
148
+
149
+ # ── Extract series ──────────────────────────────────────────────────��─────
150
+ revenue = S("revenue")
151
+ cogs = S("cost_of_revenue")
152
+ gross_profit = S("gross_profit")
153
+ op_income = S("operating_income")
154
+ ebitda = S("ebitda")
155
+ da = S("depreciation_amort")
156
+ interest_exp = S("interest_expense")
157
+ pretax_income = S("pretax_income")
158
+ tax_expense = S("tax_expense")
159
+ net_income = S("net_income")
160
+ diluted_eps = S("diluted_eps")
161
+ diluted_shares = S("diluted_shares")
162
+
163
+ total_assets = S("total_assets", bal)
164
+ total_equity = S("total_equity", bal)
165
+ total_liabilities = S("total_liabilities", bal)
166
+ total_curr_assets = S("total_current_assets", bal)
167
+ total_curr_liab = S("total_current_liabilities", bal)
168
+ inventory = S("inventory", bal)
169
+ receivables = S("receivables", bal)
170
+ payables = S("accounts_payable", bal)
171
+ short_debt = S("short_term_debt", bal)
172
+ long_debt = S("long_term_debt", bal)
173
+ cash = S("cash_and_equivalents", bal)
174
+ st_inv = S("short_term_investments", bal)
175
+
176
+ cfo = S("cash_from_operations", cf)
177
+ capex = S("capex", cf)
178
+ fcf = S("free_cash_flow", cf)
179
+ dividends = S("dividends_paid", cf)
180
+ buybacks = S("stock_buybacks", cf)
181
+
182
+ # Compute EBITDA if missing
183
+ for i in range(n):
184
+ if ebitda[i] is None and op_income[i] is not None and da[i] is not None:
185
+ ebitda[i] = op_income[i] + da[i]
186
+
187
+ # Compute FCF if missing
188
+ for i in range(n):
189
+ if fcf[i] is None and cfo[i] is not None and capex[i] is not None:
190
+ fcf[i] = cfo[i] + capex[i] # capex is usually negative in FMP
191
+
192
+ # Total debt
193
+ total_debt = [
194
+ (s or 0) + (l or 0) if (s is not None or l is not None) else None
195
+ for s, l in zip(short_debt, long_debt)
196
+ ]
197
+
198
+ # Effective tax rate
199
+ eff_tax_rate = [
200
+ _safe_div(t, p) if p and p > 0 else None
201
+ for t, p in zip(tax_expense, pretax_income)
202
+ ]
203
+
204
+ # NOPAT
205
+ nopat = [
206
+ o * (1 - (t or 0)) if o is not None else None
207
+ for o, t in zip(op_income, eff_tax_rate)
208
+ ]
209
+
210
+ # Invested capital = Total equity + Total debt - Cash
211
+ inv_capital = [
212
+ (e or 0) + (d or 0) - (c or 0)
213
+ if e is not None or d is not None else None
214
+ for e, d, c in zip(total_equity, total_debt, cash)
215
+ ]
216
+
217
+ # ── Profitability ratios ──────────────────────────────────────────────────
218
+ gross_margin = [_safe_div(g, r) for g, r in zip(gross_profit, revenue)]
219
+ op_margin = [_safe_div(o, r) for o, r in zip(op_income, revenue)]
220
+ ebitda_margin = [_safe_div(e, r) for e, r in zip(ebitda, revenue)]
221
+ net_margin = [_safe_div(n_, r) for n_, r in zip(net_income, revenue)]
222
+
223
+ # ROE, ROA, ROIC use averages
224
+ roe = [_safe_div(n_, _avg(e0, e1)) for n_, e0, e1 in zip(
225
+ net_income, [None] + total_equity[:-1], total_equity)]
226
+ roa = [_safe_div(n_, _avg(a0, a1)) for n_, a0, a1 in zip(
227
+ net_income, [None] + total_assets[:-1], total_assets)]
228
+ roic = [_safe_div(no, _avg(ic0, ic1)) for no, ic0, ic1 in zip(
229
+ nopat, [None] + inv_capital[:-1], inv_capital)]
230
+
231
+ # ── Liquidity ─────────────────────────────────────────────────────────────
232
+ current_ratio = [_safe_div(ca, cl) for ca, cl in zip(total_curr_assets, total_curr_liab)]
233
+ quick_ratio = [
234
+ _safe_div((ca or 0) - (inv or 0), cl)
235
+ if ca is not None and cl else None
236
+ for ca, inv, cl in zip(total_curr_assets, inventory, total_curr_liab)
237
+ ]
238
+ cash_ratio = [
239
+ _safe_div((c or 0) + (si or 0), cl)
240
+ if cl else None
241
+ for c, si, cl in zip(cash, st_inv, total_curr_liab)
242
+ ]
243
+
244
+ # ── Solvency / Leverage ───────────────────────────────────────────────────
245
+ debt_to_equity = [_safe_div(d, e) for d, e in zip(total_debt, total_equity)]
246
+ debt_to_assets = [_safe_div(d, a) for d, a in zip(total_debt, total_assets)]
247
+ net_debt = [(d or 0) - (c or 0) - (si or 0) if d is not None else None
248
+ for d, c, si in zip(total_debt, cash, st_inv)]
249
+ net_debt_ebitda = [_safe_div(nd, e) for nd, e in zip(net_debt, ebitda)]
250
+ interest_cov = [_safe_div(o, ie) for o, ie in zip(op_income, interest_exp)]
251
+
252
+ # ── Efficiency ────────────────────────────────────────────────────────────
253
+ asset_turnover = [_safe_div(r, _avg(a0, a1)) for r, a0, a1 in zip(
254
+ revenue, [None] + total_assets[:-1], total_assets)]
255
+ inv_turnover = [_safe_div(c, _avg(i0, i1)) for c, i0, i1 in zip(
256
+ cogs, [None] + inventory[:-1], inventory)]
257
+ days_inv = [_safe_div(365, it) for it in inv_turnover]
258
+ rec_turnover = [_safe_div(r, _avg(rc0, rc1)) for r, rc0, rc1 in zip(
259
+ revenue, [None] + receivables[:-1], receivables)]
260
+ days_sales = [_safe_div(365, rt) for rt in rec_turnover]
261
+ pay_turnover = [_safe_div(c, _avg(p0, p1)) for c, p0, p1 in zip(
262
+ cogs, [None] + payables[:-1], payables)]
263
+ days_payable = [_safe_div(365, pt) for pt in pay_turnover]
264
+ ccc = [
265
+ (di or 0) + (ds or 0) - (dp or 0)
266
+ if di is not None and ds is not None and dp is not None else None
267
+ for di, ds, dp in zip(days_inv, days_sales, days_payable)
268
+ ]
269
+
270
+ # ── Cash flow quality ─────────────────────────────────────────────────────
271
+ ocf_to_ni = [_safe_div(c, n_) for c, n_ in zip(cfo, net_income)]
272
+ fcf_margin_ = [_safe_div(f, r) for f, r in zip(fcf, revenue)]
273
+ fcf_conversion = [_safe_div(f, n_) for f, n_ in zip(fcf, net_income)]
274
+ capex_intensity = [_safe_div(abs(c) if c else None, r) for c, r in zip(capex, revenue)]
275
+
276
+ # ── Growth metrics ────────────────────────────────────────────────────────
277
+ def yoy(arr):
278
+ result = [None]
279
+ for i in range(1, len(arr)):
280
+ result.append(_pct_change(arr[i], arr[i-1]))
281
+ return result
282
+
283
+ rev_yoy = yoy(revenue)
284
+ op_yoy = yoy(op_income)
285
+ ni_yoy = yoy(net_income)
286
+ eps_yoy = yoy(diluted_eps)
287
+ fcf_yoy = yoy(fcf)
288
+
289
+ def cagr_5y(arr):
290
+ valid = [v for v in arr if v is not None]
291
+ if len(valid) < 2:
292
+ return None
293
+ return _cagr(valid[0], valid[-1], len(valid) - 1)
294
+
295
+ growth_metrics = {
296
+ "revenue_yoy": rev_yoy,
297
+ "revenue_cagr_5y": cagr_5y(revenue),
298
+ "operating_income_yoy": op_yoy,
299
+ "operating_income_cagr_5y": cagr_5y(op_income),
300
+ "net_income_yoy": ni_yoy,
301
+ "net_income_cagr_5y": cagr_5y(net_income),
302
+ "eps_yoy": eps_yoy,
303
+ "eps_cagr_5y": cagr_5y(diluted_eps),
304
+ "fcf_yoy": fcf_yoy,
305
+ "fcf_cagr_5y": cagr_5y(fcf),
306
+ }
307
+
308
+ # ── DuPont decomposition ──────────────────────────────────────────────────
309
+ # ROE = Net Margin × Asset Turnover × Equity Multiplier
310
+ equity_multiplier = [_safe_div(a, e) for a, e in zip(total_assets, total_equity)]
311
+ dupont = {
312
+ "net_margin": net_margin,
313
+ "asset_turnover": asset_turnover,
314
+ "equity_multiplier": equity_multiplier,
315
+ "roe_computed": [
316
+ (nm or 0) * (at or 0) * (em or 0)
317
+ if nm is not None and at is not None and em is not None else None
318
+ for nm, at, em in zip(net_margin, asset_turnover, equity_multiplier)
319
+ ],
320
+ "roe_reported": roe,
321
+ }
322
+
323
+ # ── Ratios dict ───────────────────────────────────────────────────────────
324
+ ratios = {
325
+ "gross_margin": gross_margin,
326
+ "operating_margin": op_margin,
327
+ "ebitda_margin": ebitda_margin,
328
+ "net_margin": net_margin,
329
+ "roe": roe,
330
+ "roa": roa,
331
+ "roic": roic,
332
+ "current_ratio": current_ratio,
333
+ "quick_ratio": quick_ratio,
334
+ "cash_ratio": cash_ratio,
335
+ "debt_to_equity": debt_to_equity,
336
+ "debt_to_assets": debt_to_assets,
337
+ "net_debt_to_ebitda": net_debt_ebitda,
338
+ "interest_coverage": interest_cov,
339
+ "asset_turnover": asset_turnover,
340
+ "inventory_turnover": inv_turnover,
341
+ "days_inventory": days_inv,
342
+ "days_sales_outstanding": days_sales,
343
+ "days_payables": days_payable,
344
+ "cash_conversion_cycle": ccc,
345
+ "ocf_to_net_income": ocf_to_ni,
346
+ "fcf_margin": fcf_margin_,
347
+ "fcf_conversion": fcf_conversion,
348
+ "capex_intensity": capex_intensity,
349
+ }
350
+
351
+ # Build final output
352
+ result = dict(financial_data)
353
+ result["ratios"] = ratios
354
+ result["growth_metrics"] = growth_metrics
355
+ result["dupont"] = dupont
356
+ result["nopat"] = nopat
357
+ result["total_debt"] = total_debt
358
+ result["net_debt"] = net_debt
359
+ result["currency"] = currency
360
+
361
+ return result
pipeline/fetcher.py ADDED
@@ -0,0 +1,542 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline/fetcher.py — Fetch 5-year financials.
3
+ Priority: yfinance (free, Indian stocks) → FMP → screener.in
4
+ """
5
+ from __future__ import annotations
6
+ import sys, re, json, datetime
7
+ from pathlib import Path
8
+ from typing import Optional, List, Dict, Any
9
+
10
+ import requests
11
+ from bs4 import BeautifulSoup
12
+
13
+ sys.path.insert(0, str(Path(__file__).parent.parent))
14
+ from config import FMP_API_KEY, FMP_BASE, REQUEST_TIMEOUT, DEFAULT_YEARS
15
+ from storage.cache import get_cached, set_cache
16
+ from pipeline.resolver import CompanyMeta
17
+
18
+ HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
19
+
20
+
21
+ # ── Helpers ───────────────────────────────────────────────────────────────────
22
+
23
+ def _s(v, default=None):
24
+ if v is None or (isinstance(v, float) and v != v): return default
25
+ return v
26
+
27
+
28
+ def _row(df, *keys):
29
+ """Get a row from a yfinance DataFrame by trying multiple key names."""
30
+ for k in keys:
31
+ if k in df.index:
32
+ return df.loc[k].tolist()
33
+ return [None] * len(df.columns)
34
+
35
+
36
+ # ── yfinance fetcher (primary for Indian + global) ───────────────────────────
37
+
38
+ def _fetch_yfinance(ticker: str, years: int, log_fn=None) -> Optional[Dict]:
39
+ def log(m):
40
+ if log_fn: log_fn(m)
41
+ try:
42
+ import yfinance as yf
43
+ log(f" → yfinance: fetching {ticker} …")
44
+ t = yf.Ticker(ticker)
45
+
46
+ fin = t.financials # newest col first
47
+ bs = t.balance_sheet
48
+ cf = t.cashflow
49
+ info = t.info
50
+
51
+ if fin is None or fin.empty:
52
+ log(" ✗ yfinance returned empty financials")
53
+ return None
54
+
55
+ # Year labels — columns are Timestamps, newest first; we want oldest→newest
56
+ all_cols = list(fin.columns) # newest first
57
+ use_cols = all_cols[:years] # take up to `years` newest
58
+ use_cols_asc = list(reversed(use_cols)) # now oldest→newest
59
+ year_labels = []
60
+ for c in use_cols_asc:
61
+ try:
62
+ year_labels.append(f"{c.year}/{c.month:02d}")
63
+ except Exception:
64
+ year_labels.append(str(c)[:7])
65
+
66
+ n = len(year_labels)
67
+
68
+ def col(df, *keys):
69
+ """Extract a row from df, return list oldest→newest, length n."""
70
+ for k in keys:
71
+ if df is not None and not df.empty and k in df.index:
72
+ # df columns are newest first; slice to use_cols then reverse
73
+ vals = []
74
+ for c in use_cols_asc: # iterate oldest→newest
75
+ if c in df.columns:
76
+ v = df.at[k, c]
77
+ vals.append(None if (v != v or v is None) else float(v))
78
+ else:
79
+ vals.append(None)
80
+ return vals
81
+ return [None] * n
82
+
83
+ currency = info.get("currency", "INR")
84
+
85
+ # Determine display scale
86
+ # INR: raw values are in rupees → divide by 1e7 to get Crores
87
+ # USD/others: raw values in dollars → divide by 1e6 for Millions
88
+ if currency == "INR":
89
+ scale = 1e7 # rupees → crores
90
+ units = "Crores"
91
+ units_label = "INR Crores (₹ Cr)"
92
+ else:
93
+ scale = 1e6 # dollars → millions
94
+ units = "Millions"
95
+ units_label = f"{currency} Millions"
96
+
97
+ def sc(lst):
98
+ """Scale a list of raw values."""
99
+ return [round(v / scale, 2) if v is not None else None for v in lst]
100
+
101
+ income = {
102
+ "revenue": sc(col(fin, "Total Revenue", "Operating Revenue")),
103
+ "cost_of_revenue": sc(col(fin, "Cost Of Revenue", "Reconciled Cost Of Revenue")),
104
+ "gross_profit": sc(col(fin, "Gross Profit")),
105
+ "operating_expenses": sc(col(fin, "Operating Expense")),
106
+ "operating_income": sc(col(fin, "Operating Income", "EBIT")),
107
+ "ebitda": sc(col(fin, "EBITDA", "Normalized EBITDA")),
108
+ "depreciation_amort": sc(col(fin, "Reconciled Depreciation", "Depreciation And Amortization")),
109
+ "interest_expense": sc(col(fin, "Interest Expense Non Operating", "Interest Expense")),
110
+ "pretax_income": sc(col(fin, "Pretax Income")),
111
+ "tax_expense": sc(col(fin, "Tax Provision")),
112
+ "net_income": sc(col(fin, "Net Income", "Net Income Common Stockholders")),
113
+ "diluted_eps": col(fin, "Diluted EPS"), # EPS not scaled
114
+ "diluted_shares": col(fin, "Diluted Average Shares"), # not scaled
115
+ }
116
+
117
+ balance = {
118
+ "cash_and_equivalents": sc(col(bs, "Cash And Cash Equivalents", "Cash Financial")),
119
+ "short_term_investments": sc(col(bs, "Other Short Term Investments")),
120
+ "receivables": sc(col(bs, "Accounts Receivable", "Other Receivables")),
121
+ "inventory": sc(col(bs, "Inventory")),
122
+ "other_current_assets": sc(col(bs, "Other Current Assets")),
123
+ "total_current_assets": sc(col(bs, "Current Assets")),
124
+ "ppe_net": sc(col(bs, "Net PPE")),
125
+ "goodwill": sc(col(bs, "Goodwill")),
126
+ "intangibles": sc(col(bs, "Other Intangible Assets")),
127
+ "other_long_term_assets": sc(col(bs, "Other Non Current Assets")),
128
+ "total_assets": sc(col(bs, "Total Assets")),
129
+ "accounts_payable": sc(col(bs, "Accounts Payable", "Payables")),
130
+ "short_term_debt": sc(col(bs, "Current Debt", "Current Debt And Capital Lease Obligation")),
131
+ "other_current_liabilities": sc(col(bs, "Other Current Liabilities")),
132
+ "total_current_liabilities": sc(col(bs, "Current Liabilities")),
133
+ "long_term_debt": sc(col(bs, "Long Term Debt", "Long Term Debt And Capital Lease Obligation")),
134
+ "other_long_term_liabilities":sc(col(bs, "Other Non Current Liabilities")),
135
+ "total_liabilities": sc(col(bs, "Total Liabilities Net Minority Interest")),
136
+ "total_equity": sc(col(bs, "Stockholders Equity", "Common Stock Equity")),
137
+ "total_liab_and_equity": sc(col(bs, "Total Assets")),
138
+ }
139
+
140
+ cashflow = {
141
+ "net_income": sc(col(cf, "Net Income From Continuing Operations")),
142
+ "depreciation_amort": sc(col(cf, "Depreciation And Amortization", "Depreciation")),
143
+ "stock_based_comp": [None] * n,
144
+ "working_capital_changes": sc(col(cf, "Change In Working Capital")),
145
+ "other_operating": sc(col(cf, "Other Non Cash Items")),
146
+ "cash_from_operations": sc(col(cf, "Operating Cash Flow")),
147
+ "capex": sc(col(cf, "Capital Expenditure", "Purchase Of PPE")),
148
+ "acquisitions": [None] * n,
149
+ "cash_from_investing": sc(col(cf, "Investing Cash Flow")),
150
+ "debt_issuance_repayment": sc(col(cf, "Net Long Term Debt Issuance")),
151
+ "stock_buybacks": sc(col(cf, "Net Common Stock Issuance")),
152
+ "dividends_paid": sc(col(cf, "Cash Dividends Paid")),
153
+ "cash_from_financing": sc(col(cf, "Financing Cash Flow")),
154
+ "net_change_in_cash": sc(col(cf, "Changes In Cash")),
155
+ "free_cash_flow": sc(col(cf, "Free Cash Flow")),
156
+ }
157
+
158
+ # Compute FCF if missing
159
+ for i in range(n):
160
+ if cashflow["free_cash_flow"][i] is None:
161
+ cfo = cashflow["cash_from_operations"][i]
162
+ cap = cashflow["capex"][i]
163
+ if cfo is not None and cap is not None:
164
+ cashflow["free_cash_flow"][i] = round(cfo + cap, 2)
165
+
166
+ log(f" ✓ yfinance data ready ({n} years, {currency}, in {units})")
167
+
168
+ return {
169
+ "years": year_labels,
170
+ "income_statement": income,
171
+ "balance_sheet": balance,
172
+ "cash_flow": cashflow,
173
+ "currency": currency,
174
+ "units": units,
175
+ "units_label": units_label,
176
+ "scale": scale,
177
+ "source": "Yahoo Finance (yfinance)",
178
+ "valuation_snapshot": {
179
+ "pe_ttm": _s(info.get("trailingPE")),
180
+ "pb": _s(info.get("priceToBook")),
181
+ "ps": _s(info.get("priceToSalesTrailing12Months")),
182
+ "ev_ebitda": _s(info.get("enterpriseToEbitda")),
183
+ "ev_revenue": _s(info.get("enterpriseToRevenue")),
184
+ "fcf_yield": None,
185
+ "dividend_yield": _s(info.get("dividendYield")),
186
+ },
187
+ }
188
+ except Exception as e:
189
+ import traceback
190
+ if log_fn: log_fn(f" ✗ yfinance error: {e}\n{traceback.format_exc()}")
191
+ return None
192
+
193
+
194
+ # ── FMP fetcher (secondary for global) ───────────────────────────────────────
195
+
196
+ def _fmp_get(path, params=None):
197
+ if not FMP_API_KEY: return None
198
+ p = {"apikey": FMP_API_KEY}
199
+ if params: p.update(params)
200
+ try:
201
+ r = requests.get(f"{FMP_BASE}{path}", params=p, timeout=REQUEST_TIMEOUT, headers=HEADERS)
202
+ r.raise_for_status()
203
+ return r.json()
204
+ except Exception:
205
+ return None
206
+
207
+
208
+ def _normalise_fmp(raw, years):
209
+ inc = list(reversed(raw.get("income", [])))[:years]
210
+ bal = list(reversed(raw.get("balance", [])))[:years]
211
+ cf = list(reversed(raw.get("cashflow", [])))[:years]
212
+
213
+ def col(lst, key):
214
+ return [_s(d.get(key)) for d in lst]
215
+
216
+ year_labels = [d.get("calendarYear", str(d.get("date",""))[:4]) for d in inc]
217
+ income = {
218
+ "revenue": col(inc, "revenue"),
219
+ "cost_of_revenue": col(inc, "costOfRevenue"),
220
+ "gross_profit": col(inc, "grossProfit"),
221
+ "operating_expenses": col(inc, "operatingExpenses"),
222
+ "operating_income": col(inc, "operatingIncome"),
223
+ "ebitda": col(inc, "ebitda"),
224
+ "depreciation_amort": col(inc, "depreciationAndAmortization"),
225
+ "interest_expense": col(inc, "interestExpense"),
226
+ "pretax_income": col(inc, "incomeBeforeTax"),
227
+ "tax_expense": col(inc, "incomeTaxExpense"),
228
+ "net_income": col(inc, "netIncome"),
229
+ "diluted_eps": col(inc, "epsdiluted"),
230
+ "diluted_shares": col(inc, "weightedAverageShsOutDil"),
231
+ }
232
+ balance = {
233
+ "cash_and_equivalents": col(bal, "cashAndCashEquivalents"),
234
+ "short_term_investments": col(bal, "shortTermInvestments"),
235
+ "receivables": col(bal, "netReceivables"),
236
+ "inventory": col(bal, "inventory"),
237
+ "other_current_assets": col(bal, "otherCurrentAssets"),
238
+ "total_current_assets": col(bal, "totalCurrentAssets"),
239
+ "ppe_net": col(bal, "propertyPlantEquipmentNet"),
240
+ "goodwill": col(bal, "goodwill"),
241
+ "intangibles": col(bal, "intangibleAssets"),
242
+ "other_long_term_assets": col(bal, "otherNonCurrentAssets"),
243
+ "total_assets": col(bal, "totalAssets"),
244
+ "accounts_payable": col(bal, "accountPayables"),
245
+ "short_term_debt": col(bal, "shortTermDebt"),
246
+ "other_current_liabilities": col(bal, "otherCurrentLiabilities"),
247
+ "total_current_liabilities": col(bal, "totalCurrentLiabilities"),
248
+ "long_term_debt": col(bal, "longTermDebt"),
249
+ "other_long_term_liabilities":col(bal, "otherNonCurrentLiabilities"),
250
+ "total_liabilities": col(bal, "totalLiabilities"),
251
+ "total_equity": col(bal, "totalStockholdersEquity"),
252
+ "total_liab_and_equity": col(bal, "totalLiabilitiesAndStockholdersEquity"),
253
+ }
254
+ cashflow = {
255
+ "net_income": col(cf, "netIncome"),
256
+ "depreciation_amort": col(cf, "depreciationAndAmortization"),
257
+ "stock_based_comp": col(cf, "stockBasedCompensation"),
258
+ "working_capital_changes": col(cf, "changeInWorkingCapital"),
259
+ "other_operating": col(cf, "otherWorkingCapital"),
260
+ "cash_from_operations": col(cf, "netCashProvidedByOperatingActivities"),
261
+ "capex": col(cf, "capitalExpenditure"),
262
+ "acquisitions": col(cf, "acquisitionsNet"),
263
+ "cash_from_investing": col(cf, "netCashUsedForInvestingActivites"),
264
+ "debt_issuance_repayment": col(cf, "debtRepayment"),
265
+ "stock_buybacks": col(cf, "commonStockRepurchased"),
266
+ "dividends_paid": col(cf, "dividendsPaid"),
267
+ "cash_from_financing": col(cf, "netCashUsedProvidedByFinancingActivities"),
268
+ "net_change_in_cash": col(cf, "netChangeInCash"),
269
+ "free_cash_flow": col(cf, "freeCashFlow"),
270
+ }
271
+ return {
272
+ "years": year_labels, "income_statement": income,
273
+ "balance_sheet": balance, "cash_flow": cashflow,
274
+ "currency": "USD", "units": "millions",
275
+ "source": "FMP API",
276
+ "valuation_snapshot": {},
277
+ }
278
+
279
+
280
+ def _fetch_fmp(ticker, years, log_fn=None):
281
+ def log(m):
282
+ if log_fn: log_fn(m)
283
+ if not FMP_API_KEY:
284
+ return None
285
+ log(" → FMP: fetching statements …")
286
+ lim = years + 1
287
+ raw = {
288
+ "income": _fmp_get(f"/v3/income-statement/{ticker}", {"limit": lim, "period": "annual"}) or [],
289
+ "balance": _fmp_get(f"/v3/balance-sheet-statement/{ticker}", {"limit": lim, "period": "annual"}) or [],
290
+ "cashflow": _fmp_get(f"/v3/cash-flow-statement/{ticker}", {"limit": lim, "period": "annual"}) or [],
291
+ }
292
+ if not raw["income"]:
293
+ log(" ✗ FMP returned no data")
294
+ return None
295
+ log(" ✓ FMP data ready")
296
+ data = _normalise_fmp(raw, years)
297
+ # Add valuation
298
+ rtm = (_fmp_get(f"/v3/ratios-ttm/{ticker}") or [{}])[0]
299
+ km = (_fmp_get(f"/v3/key-metrics-ttm/{ticker}") or [{}])[0]
300
+ data["valuation_snapshot"] = {
301
+ "pe_ttm": _s(rtm.get("peRatioTTM")),
302
+ "pb": _s(rtm.get("priceToBookRatioTTM")),
303
+ "ps": _s(rtm.get("priceToSalesRatioTTM")),
304
+ "ev_ebitda": _s(km.get("enterpriseValueOverEBITDATTM")),
305
+ "ev_revenue": _s(km.get("evToSalesTTM")),
306
+ "fcf_yield": _s(km.get("freeCashFlowYieldTTM")),
307
+ "dividend_yield": _s(rtm.get("dividendYieldTTM")),
308
+ }
309
+ return data
310
+
311
+
312
+ # ── screener.in (Indian fallback) ────────────────────────────────────────────
313
+
314
+ def _scrape_screener(name: str, log_fn=None) -> Optional[Dict]:
315
+ def log(m):
316
+ if log_fn: log_fn(m)
317
+ slug = re.sub(r"[^a-z0-9]+", "-", name.lower().strip()).strip("-")
318
+ for url in [f"https://www.screener.in/company/{slug}/",
319
+ f"https://www.screener.in/company/{slug.upper()}/"]:
320
+ try:
321
+ log(f" → screener.in: {url}")
322
+ r = requests.get(url, timeout=REQUEST_TIMEOUT, headers=HEADERS)
323
+ if r.status_code == 200:
324
+ soup = BeautifulSoup(r.text, "lxml")
325
+ sections = {}
326
+ for sec_id, key in [("profit-loss","income"),("balance-sheet","balance"),
327
+ ("cash-flow","cashflow")]:
328
+ sec = soup.find("section", {"id": sec_id})
329
+ if sec:
330
+ table = sec.find("table")
331
+ if table:
332
+ result = {}
333
+ for row in table.find_all("tr"):
334
+ cells = row.find_all(["td","th"])
335
+ if len(cells) < 2: continue
336
+ label = cells[0].get_text(strip=True)
337
+ vals = []
338
+ for c in cells[1:]:
339
+ t2 = c.get_text(strip=True).replace(",","").replace("+","")
340
+ try: vals.append(float(t2))
341
+ except: vals.append(None)
342
+ if label: result[label] = vals
343
+ sections[key] = result
344
+ if "years" not in sections:
345
+ hdr = table.find("tr")
346
+ if hdr:
347
+ sections["years"] = [c.get_text(strip=True)
348
+ for c in hdr.find_all(["th","td"])[1:]]
349
+ if sections:
350
+ sections["source_url"] = url
351
+ log(" ✓ screener.in scraped")
352
+ return sections
353
+ except Exception as e:
354
+ log(f" ✗ screener.in error: {e}")
355
+ return None
356
+
357
+
358
+ def _normalise_screener(raw, years):
359
+ yr = raw.get("years", [])[:years]
360
+ n = len(yr)
361
+ inc = raw.get("income", {})
362
+ bal = raw.get("balance", {})
363
+ cf = raw.get("cashflow", {})
364
+
365
+ def row(*keys):
366
+ for k in keys:
367
+ if k in inc: return inc[k][:n]
368
+ if k in bal: return bal[k][:n]
369
+ if k in cf: return cf[k][:n]
370
+ return [None]*n
371
+
372
+ income = {
373
+ "revenue": row("Revenue","Net Sales","Sales"),
374
+ "cost_of_revenue": row("Expenses","Cost of Goods Sold"),
375
+ "gross_profit": row("Gross Profit"),
376
+ "operating_expenses": [None]*n,
377
+ "operating_income": row("Operating Profit","EBIT","PBDIT"),
378
+ "ebitda": row("PBDIT","EBITDA"),
379
+ "depreciation_amort": row("Depreciation"),
380
+ "interest_expense": row("Interest"),
381
+ "pretax_income": row("Profit before tax","PBT"),
382
+ "tax_expense": row("Tax"),
383
+ "net_income": row("Net Profit","PAT"),
384
+ "diluted_eps": row("EPS in Rs"),
385
+ "diluted_shares": [None]*n,
386
+ }
387
+ balance = {
388
+ "cash_and_equivalents": row("Cash Equivalents"),
389
+ "short_term_investments": [None]*n,
390
+ "receivables": [None]*n,
391
+ "inventory": [None]*n,
392
+ "other_current_assets": [None]*n,
393
+ "total_current_assets": row("Current Assets"),
394
+ "ppe_net": row("Fixed Assets"),
395
+ "goodwill": [None]*n,
396
+ "intangibles": [None]*n,
397
+ "other_long_term_assets": [None]*n,
398
+ "total_assets": row("Total Assets","Balance Sheet Size"),
399
+ "accounts_payable": [None]*n,
400
+ "short_term_debt": row("Short Term Borrowing"),
401
+ "other_current_liabilities": [None]*n,
402
+ "total_current_liabilities": row("Current Liabilities"),
403
+ "long_term_debt": row("Borrowings","Long Term Borrowing"),
404
+ "other_long_term_liabilities":[None]*n,
405
+ "total_liabilities": row("Total Liabilities"),
406
+ "total_equity": row("Equity Capital","Total Equity"),
407
+ "total_liab_and_equity": row("Total Assets","Balance Sheet Size"),
408
+ }
409
+ cfo_vals = row("Cash from Operating Activity","Cash from Operations")
410
+ cap_vals = row("Fixed Assets Purchased","Capital Expenditure")
411
+ fcf_vals = [(c - abs(x)) if c is not None and x is not None else None
412
+ for c, x in zip(cfo_vals, cap_vals)]
413
+ cashflow = {
414
+ "net_income": income["net_income"],
415
+ "depreciation_amort": income["depreciation_amort"],
416
+ "stock_based_comp": [None]*n,
417
+ "working_capital_changes": [None]*n,
418
+ "other_operating": [None]*n,
419
+ "cash_from_operations": cfo_vals,
420
+ "capex": cap_vals,
421
+ "acquisitions": [None]*n,
422
+ "cash_from_investing": row("Cash from Investing Activity"),
423
+ "debt_issuance_repayment": [None]*n,
424
+ "stock_buybacks": [None]*n,
425
+ "dividends_paid": row("Dividend Amount"),
426
+ "cash_from_financing": row("Cash from Financing Activity"),
427
+ "net_change_in_cash": row("Net Cash Flow"),
428
+ "free_cash_flow": fcf_vals,
429
+ }
430
+ return {
431
+ "years": yr, "income_statement": income, "balance_sheet": balance, "cash_flow": cashflow,
432
+ "currency": "INR", "units": "crores",
433
+ "source": f"screener.in ({raw.get('source_url','')})",
434
+ "valuation_snapshot": {},
435
+ }
436
+
437
+
438
+ # ── Peer snapshot ─────────────────────────────────────────────────────────────
439
+
440
+ def fetch_peer_snapshot(ticker: str) -> Optional[Dict]:
441
+ try:
442
+ import yfinance as yf
443
+ t = yf.Ticker(ticker)
444
+ info = t.info
445
+ if not info.get("longName"): return None
446
+ fin = t.financials
447
+ rev = fin.loc["Total Revenue"].iloc[0] if fin is not None and "Total Revenue" in fin.index else None
448
+ ratios_ttm = (_fmp_get(f"/v3/ratios-ttm/{ticker}") or [{}])[0]
449
+ return {
450
+ "name": info.get("longName", ticker),
451
+ "ticker": ticker,
452
+ "rationale": f"Industry peer ({info.get('industry','')})",
453
+ "latest_year": "",
454
+ "currency": info.get("currency","INR"),
455
+ "units": "actuals",
456
+ "revenue": _s(rev),
457
+ "gross_margin": _s(info.get("grossMargins")),
458
+ "operating_margin": _s(info.get("operatingMargins")),
459
+ "net_margin": _s(info.get("profitMargins")),
460
+ "roe": _s(info.get("returnOnEquity")),
461
+ "roic": _s(ratios_ttm.get("returnOnCapitalEmployedTTM")),
462
+ "debt_to_equity": _s(info.get("debtToEquity")),
463
+ "current_ratio": _s(info.get("currentRatio")),
464
+ "fcf_margin": None,
465
+ "revenue_growth_3y_cagr": _s(info.get("revenueGrowth")),
466
+ "pe_ttm": _s(info.get("trailingPE")),
467
+ "ev_ebitda": _s(info.get("enterpriseToEbitda")),
468
+ }
469
+ except Exception:
470
+ return None
471
+
472
+
473
+ def fetch_valuation(ticker: str) -> Dict:
474
+ try:
475
+ import yfinance as yf
476
+ info = yf.Ticker(ticker).info
477
+ return {
478
+ "pe_ttm": _s(info.get("trailingPE")),
479
+ "pb": _s(info.get("priceToBook")),
480
+ "ps": _s(info.get("priceToSalesTrailing12Months")),
481
+ "ev_ebitda": _s(info.get("enterpriseToEbitda")),
482
+ "ev_revenue": _s(info.get("enterpriseToRevenue")),
483
+ "fcf_yield": None,
484
+ "dividend_yield": _s(info.get("dividendYield")),
485
+ }
486
+ except Exception:
487
+ return {}
488
+
489
+
490
+ # ── Main entry ────────────────────────────────────────────────────────────────
491
+
492
+ def fetch_all(meta: CompanyMeta, years: int = DEFAULT_YEARS,
493
+ force_refresh: bool = False, log_fn=None) -> Dict:
494
+ def log(m):
495
+ if log_fn: log_fn(m)
496
+
497
+ if not meta.is_listed:
498
+ msg = (f"'{meta.name}' is unlisted/private. Public data not available. "
499
+ "Try the exact BSE/NSE ticker.")
500
+ log(f" ⚠ {msg}")
501
+ return {"warnings": [msg], "years": [], "income_statement": {},
502
+ "balance_sheet": {}, "cash_flow": {}}
503
+
504
+ ticker = meta.ticker
505
+ cache_key = f"full_{years}yr"
506
+ if not force_refresh:
507
+ cached = get_cached(ticker, cache_key)
508
+ if cached:
509
+ log(f" ✓ Loaded cached data for {ticker}")
510
+ return cached
511
+
512
+ warnings = []
513
+ result = None
514
+
515
+ # 1. yfinance (primary — free, excellent Indian coverage)
516
+ log(f"Fetching data for {meta.name} ({ticker}) …")
517
+ result = _fetch_yfinance(ticker, years, log_fn)
518
+
519
+ # 2. FMP (secondary — global companies)
520
+ if result is None and FMP_API_KEY:
521
+ result = _fetch_fmp(ticker, years, log_fn)
522
+
523
+ # 3. screener.in (Indian fallback)
524
+ if result is None and meta.currency == "INR":
525
+ log(" → Trying screener.in …")
526
+ scraped = _scrape_screener(meta.name, log_fn)
527
+ if scraped:
528
+ result = _normalise_screener(scraped, years)
529
+ warnings.append("Data from screener.in (web scrape). Verify against company filings.")
530
+
531
+ if result is None:
532
+ msg = (f"Could not fetch data for '{meta.name}' ({ticker}). "
533
+ "Check ticker, or the company may have limited public data coverage.")
534
+ log(f" ⚠ {msg}")
535
+ return {"warnings": [msg], "years": [], "income_statement": {},
536
+ "balance_sheet": {}, "cash_flow": {}}
537
+
538
+ result["warnings"] = warnings
539
+ result.setdefault("currency", meta.currency)
540
+ set_cache(ticker, cache_key, result)
541
+ log(" ✓ Data cached for 7 days")
542
+ return result
pipeline/google_login.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline/google_login.py — Google OAuth login for Gemini API access.
3
+
4
+ Uses google-auth-oauthlib InstalledAppFlow to authenticate via browser.
5
+ After login, the access token is used directly with the Generative Language REST API
6
+ so the user NEVER needs to touch an API key manually.
7
+
8
+ Credentials are stored in storage/google_token.json and auto-refreshed.
9
+ """
10
+ from __future__ import annotations
11
+ import json, time, threading
12
+ from pathlib import Path
13
+ from typing import Optional, Tuple
14
+
15
+ # Where we persist the user's OAuth token
16
+ _TOKEN_FILE = Path(__file__).parent.parent / "storage" / "google_token.json"
17
+
18
+ # Gemini generative language scope
19
+ _SCOPES = ["https://www.googleapis.com/auth/generative-language"]
20
+
21
+ # ── Bundled OAuth client credentials ─────────────────────────────────────────
22
+ # These are "Desktop app" / "Installed app" OAuth 2.0 credentials.
23
+ # They are intentionally public-facing (like VS Code's GitHub OAuth client).
24
+ # They allow users to log in with Google; they cannot access anything beyond
25
+ # what the user explicitly authorises. No secrets are exposed.
26
+ _CLIENT_CONFIG = {
27
+ "installed": {
28
+ "client_id": "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com",
29
+ "client_secret": "d-FL95Q19q7MQmFpd7hHD0Ty",
30
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
31
+ "token_uri": "https://oauth2.googleapis.com/token",
32
+ "redirect_uris": ["urn:ietf:wg:oauth:2.0:oob", "http://localhost"],
33
+ }
34
+ }
35
+
36
+
37
+ # ── Core OAuth helpers ────────────────────────────────────────────────────────
38
+
39
+ def _load_token() -> Optional[dict]:
40
+ if _TOKEN_FILE.exists():
41
+ try:
42
+ return json.loads(_TOKEN_FILE.read_text(encoding="utf-8"))
43
+ except Exception:
44
+ pass
45
+ return None
46
+
47
+
48
+ def _save_token(token_data: dict):
49
+ _TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
50
+ _TOKEN_FILE.write_text(json.dumps(token_data, indent=2), encoding="utf-8")
51
+
52
+
53
+ def _refresh_token(token_data: dict) -> Tuple[bool, dict]:
54
+ """Use the refresh_token to get a new access_token."""
55
+ try:
56
+ import requests
57
+ r = requests.post(
58
+ _CLIENT_CONFIG["installed"]["token_uri"],
59
+ data={
60
+ "client_id": _CLIENT_CONFIG["installed"]["client_id"],
61
+ "client_secret": _CLIENT_CONFIG["installed"]["client_secret"],
62
+ "refresh_token": token_data.get("refresh_token", ""),
63
+ "grant_type": "refresh_token",
64
+ },
65
+ timeout=15,
66
+ )
67
+ resp = r.json()
68
+ if "access_token" in resp:
69
+ token_data["access_token"] = resp["access_token"]
70
+ token_data["expires_at"] = time.time() + resp.get("expires_in", 3600)
71
+ _save_token(token_data)
72
+ return True, token_data
73
+ return False, token_data
74
+ except Exception as e:
75
+ return False, token_data
76
+
77
+
78
+ def get_valid_token() -> Optional[str]:
79
+ """Return a valid access_token, refreshing if needed. None if not logged in."""
80
+ token_data = _load_token()
81
+ if not token_data:
82
+ return None
83
+ if time.time() > token_data.get("expires_at", 0) - 60:
84
+ ok, token_data = _refresh_token(token_data)
85
+ if not ok:
86
+ return None
87
+ return token_data.get("access_token")
88
+
89
+
90
+ def is_logged_in() -> bool:
91
+ return get_valid_token() is not None
92
+
93
+
94
+ def logout():
95
+ if _TOKEN_FILE.exists():
96
+ _TOKEN_FILE.unlink()
97
+
98
+
99
+ # ── Browser login flow ────────────────────────────────────────────────────────
100
+
101
+ def login_with_browser(log_fn=None) -> Tuple[bool, str]:
102
+ """
103
+ Launch Google OAuth in the default browser.
104
+ Blocks until auth completes or fails.
105
+ Returns (success, message).
106
+ """
107
+ def log(m):
108
+ if log_fn: log_fn(m)
109
+ try:
110
+ from google_auth_oauthlib.flow import InstalledAppFlow
111
+ except ImportError:
112
+ return False, (
113
+ "google-auth-oauthlib not installed.\n"
114
+ "Run: pip install google-auth-oauthlib"
115
+ )
116
+
117
+ try:
118
+ log("Opening browser for Google login…")
119
+ flow = InstalledAppFlow.from_client_config(_CLIENT_CONFIG, scopes=_SCOPES)
120
+ # Try local server first (most reliable), fallback to console input
121
+ try:
122
+ creds = flow.run_local_server(port=0, open_browser=True)
123
+ except Exception:
124
+ creds = flow.run_console()
125
+
126
+ token_data = {
127
+ "access_token": creds.token,
128
+ "refresh_token": creds.refresh_token,
129
+ "expires_at": creds.expiry.timestamp() if creds.expiry else time.time() + 3600,
130
+ }
131
+ _save_token(token_data)
132
+ log("✓ Google login successful — token saved")
133
+ return True, "Logged in successfully ✓"
134
+ except Exception as e:
135
+ return False, f"Login failed: {e}"
136
+
137
+
138
+ # ── Gemini call via REST (no API key needed when using OAuth token) ───────────
139
+
140
+ def gemini_generate_oauth(prompt: str, model: str = "gemini-2.0-flash") -> str:
141
+ """Call Gemini API using the stored OAuth token (no API key)."""
142
+ token = get_valid_token()
143
+ if not token:
144
+ raise RuntimeError("Not logged in to Google. Please sign in via Settings.")
145
+ try:
146
+ import requests
147
+ url = (f"https://generativelanguage.googleapis.com/v1beta/models/"
148
+ f"{model}:generateContent")
149
+ headers = {
150
+ "Authorization": f"Bearer {token}",
151
+ "Content-Type": "application/json",
152
+ }
153
+ body = {
154
+ "contents": [{"parts": [{"text": prompt}]}],
155
+ "generationConfig": {"temperature": 0.2, "maxOutputTokens": 1024},
156
+ }
157
+ r = requests.post(url, headers=headers, json=body, timeout=60)
158
+ r.raise_for_status()
159
+ candidates = r.json().get("candidates", [])
160
+ if candidates:
161
+ parts = candidates[0].get("content", {}).get("parts", [])
162
+ if parts:
163
+ return parts[0].get("text", "").strip()
164
+ return "[No response from Gemini]"
165
+ except Exception as e:
166
+ raise RuntimeError(f"Gemini OAuth call failed: {e}")
167
+
168
+
169
+ # ── Quick validity check ──────────────────────────────────────────────────────
170
+
171
+ def validate_google_login() -> Tuple[bool, str]:
172
+ """Test if the stored Google token works with Gemini."""
173
+ token = get_valid_token()
174
+ if not token:
175
+ return False, "Not signed in. Click 'Sign in with Google'."
176
+ try:
177
+ result = gemini_generate_oauth("Reply with the single word: OK", model="gemini-2.0-flash")
178
+ if "OK" in result or len(result) > 0:
179
+ return True, "Google login active — Gemini working ✓"
180
+ return True, "Google login active ✓"
181
+ except Exception as e:
182
+ return False, f"Token expired or invalid: {e}"
pipeline/key_validator.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline/key_validator.py — Validate API keys before running analysis.
3
+ """
4
+ from __future__ import annotations
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Tuple
8
+
9
+ sys.path.insert(0, str(Path(__file__).parent.parent))
10
+
11
+
12
+ def validate_fmp(key: str) -> Tuple[bool, str]:
13
+ """Test FMP key with a lightweight profile call."""
14
+ if not key or key.strip() in ("", "your_fmp_key_here"):
15
+ return False, "No key entered."
16
+ try:
17
+ import requests
18
+ r = requests.get(
19
+ "https://financialmodelingprep.com/api/v3/profile/AAPL",
20
+ params={"apikey": key.strip()},
21
+ timeout=10,
22
+ )
23
+ data = r.json()
24
+ if isinstance(data, list) and data and data[0].get("symbol"):
25
+ return True, f"Valid ✓ (plan: {data[0].get('exchange','FMP')})"
26
+ if isinstance(data, dict) and "Error Message" in data:
27
+ return False, f"Invalid key: {data['Error Message']}"
28
+ return False, "Unexpected response — key may be invalid."
29
+ except Exception as e:
30
+ return False, f"Connection error: {e}"
31
+
32
+
33
+ def validate_gemini(key: str) -> Tuple[bool, str]:
34
+ """Test Gemini key with a minimal generate call."""
35
+ if not key or key.strip() in ("", "your_gemini_key_here"):
36
+ return False, "No key entered."
37
+ try:
38
+ # Try new google.genai SDK first
39
+ from google import genai
40
+ client = genai.Client(api_key=key.strip())
41
+ resp = client.models.generate_content(
42
+ model="gemini-2.0-flash",
43
+ contents="Reply with the single word: OK",
44
+ )
45
+ text = resp.text.strip() if resp.text else ""
46
+ return True, f"Valid ✓ (response: '{text[:20]}')"
47
+ except Exception as e1:
48
+ # Fallback to old SDK
49
+ try:
50
+ import google.generativeai as genai_old
51
+ genai_old.configure(api_key=key.strip())
52
+ model = genai_old.GenerativeModel("gemini-1.5-flash")
53
+ resp = model.generate_content("Reply with the single word: OK")
54
+ return True, f"Valid ✓ (response: '{resp.text.strip()[:20]}')"
55
+ except Exception as e2:
56
+ msg = str(e1)
57
+ if "API_KEY_INVALID" in msg or "invalid" in msg.lower():
58
+ return False, "Invalid API key. Check and re-enter."
59
+ if "quota" in msg.lower():
60
+ return True, "Key valid but quota exceeded — will still work."
61
+ return False, f"Error: {msg[:80]}"
62
+
63
+
64
+ def validate_claude(key: str) -> Tuple[bool, str]:
65
+ """Test Claude key with a minimal message."""
66
+ if not key or key.strip() in ("", "your_claude_key_here"):
67
+ return False, "No key entered."
68
+ try:
69
+ import anthropic
70
+ client = anthropic.Anthropic(api_key=key.strip())
71
+ msg = client.messages.create(
72
+ model="claude-3-5-haiku-20241022",
73
+ max_tokens=10,
74
+ messages=[{"role": "user", "content": "Reply with the single word: OK"}],
75
+ )
76
+ text = msg.content[0].text.strip() if msg.content else ""
77
+ return True, f"Valid ✓ (response: '{text[:20]}')"
78
+ except Exception as e:
79
+ msg = str(e)
80
+ if "authentication" in msg.lower() or "401" in msg or "invalid" in msg.lower():
81
+ return False, "Invalid API key. Check and re-enter."
82
+ if "quota" in msg.lower() or "credit" in msg.lower():
83
+ return True, "Key valid but credit exhausted."
84
+ return False, f"Error: {msg[:80]}"
85
+
86
+
87
+ def validate_openai(key: str) -> Tuple[bool, str]:
88
+ """Test OpenAI key with a minimal chat completion."""
89
+ if not key or key.strip() in ("", "your_openai_key_here"):
90
+ return False, "No key entered."
91
+ try:
92
+ from openai import OpenAI
93
+ client = OpenAI(api_key=key.strip())
94
+ resp = client.chat.completions.create(
95
+ model="gpt-4o-mini",
96
+ messages=[{"role": "user", "content": "Reply with the single word: OK"}],
97
+ max_tokens=5,
98
+ )
99
+ text = resp.choices[0].message.content.strip() if resp.choices else ""
100
+ return True, f"Valid ✓ (response: '{text[:20]}')"
101
+ except Exception as e:
102
+ msg = str(e)
103
+ if "401" in msg or "Incorrect API key" in msg or "invalid_api_key" in msg:
104
+ return False, "Invalid API key. Check and re-enter."
105
+ if "quota" in msg.lower() or "insufficient" in msg.lower():
106
+ return True, "Key valid but quota/credit exhausted."
107
+ return False, f"Error: {msg[:80]}"
108
+
109
+
110
+ def validate_groq(key: str) -> tuple:
111
+ """Test Groq key with a minimal chat completion (free API)."""
112
+ if not key or key.strip() in ("", "your_groq_key_here"):
113
+ return False, "No key entered."
114
+ try:
115
+ from openai import OpenAI
116
+ client = OpenAI(
117
+ api_key=key.strip(),
118
+ base_url="https://api.groq.com/openai/v1",
119
+ )
120
+ resp = client.chat.completions.create(
121
+ model="llama-3.3-70b-versatile",
122
+ messages=[{"role": "user", "content": "Reply with the single word: OK"}],
123
+ max_tokens=5,
124
+ )
125
+ text = resp.choices[0].message.content.strip() if resp.choices else ""
126
+ return True, f"Valid ✓ (Llama 3.3 70B, response: '{text[:20]}')"
127
+ except Exception as e:
128
+ msg = str(e)
129
+ if "401" in msg or "invalid_api_key" in msg.lower() or "unauthorized" in msg.lower():
130
+ return False, "Invalid Groq API key. Check and re-enter."
131
+ if "quota" in msg.lower() or "rate" in msg.lower():
132
+ return True, "Key valid but rate-limited — try again shortly."
133
+ return False, f"Error: {msg[:80]}"
134
+
135
+
136
+ def check_any_ai_model(gemini_key: str = "", claude_key: str = "", openai_key: str = "", groq_key: str = "") -> tuple:
137
+ """Quickly check if at least one AI model is usable."""
138
+ checks = [
139
+ ("groq", groq_key, validate_groq),
140
+ ("gemini", gemini_key, validate_gemini),
141
+ ("claude", claude_key, validate_claude),
142
+ ("openai", openai_key, validate_openai),
143
+ ]
144
+ for name, key, fn in checks:
145
+ if key and key.strip():
146
+ ok, msg = fn(key)
147
+ if ok:
148
+ return True, f"{name.capitalize()}: {msg}"
149
+
150
+ return False, (
151
+ "No working AI model key found.\n\n"
152
+ "Free options (no credit card needed):\n"
153
+ " • Groq (Llama 3.3 70B) → console.groq.com\n"
154
+ " • Gemini → aistudio.google.com\n\n"
155
+ "Paid options:\n"
156
+ " • Claude → console.anthropic.com\n"
157
+ " • ChatGPT → platform.openai.com/api-keys\n\n"
158
+ "OR uncheck all AI models to run data-only analysis."
159
+ )
160
+
pipeline/peer_selector.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline/peer_selector.py — Automatically select 3-5 peer companies.
3
+
4
+ Uses FMP's peers endpoint first, then falls back to industry-based hardcoded peers.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import List, Dict, Optional
11
+
12
+ import requests
13
+
14
+ sys.path.insert(0, str(Path(__file__).parent.parent))
15
+ from config import FMP_API_KEY, FMP_BASE, REQUEST_TIMEOUT
16
+ from pipeline.resolver import CompanyMeta
17
+ from pipeline.fetcher import fetch_peer_snapshot, _fmp_get
18
+
19
+ # yfinance-based peer fetch (no API key needed)
20
+ def _yf_peers_from_industry(meta: CompanyMeta) -> list:
21
+ """Return peer tickers based on industry using hardcoded maps."""
22
+ return [] # handled by INDIAN_PEERS / GLOBAL_PEERS maps below
23
+
24
+ # ── Hardcoded industry → peer tickers (Indian + Global) ──────────────────────
25
+
26
+ INDIAN_PEERS: Dict[str, List[str]] = {
27
+ "IT Services": ["TCS.NS", "INFY.NS", "WIPRO.NS", "HCLTECH.NS", "TECHM.NS"],
28
+ "Information Technology": ["TCS.NS", "INFY.NS", "WIPRO.NS", "HCLTECH.NS", "LTIM.NS"],
29
+ "Banking": ["HDFCBANK.NS", "ICICIBANK.NS", "AXISBANK.NS", "SBIN.NS", "KOTAKBANK.NS"],
30
+ "Finance": ["BAJFINANCE.NS", "BAJAJFINSV.NS", "HDFCAMC.NS", "MUTHOOTFIN.NS"],
31
+ "Pharmaceuticals": ["SUNPHARMA.NS", "CIPLA.NS", "DRREDDY.NS", "DIVISLAB.NS", "AUROPHARMA.NS"],
32
+ "Automobile": ["MARUTI.NS", "TATAMOTORS.NS", "M&M.NS", "HEROMOTOCO.NS", "BAJAJ-AUTO.NS"],
33
+ "FMCG": ["HINDUNILVR.NS", "ITC.NS", "NESTLEIND.NS", "DABUR.NS", "MARICO.NS"],
34
+ "Cement": ["ULTRACEMCO.NS", "SHREECEM.NS", "AMBUJACEM.NS", "ACC.NS", "GRASIM.NS"],
35
+ "Steel": ["TATASTEEL.NS", "JSWSTEEL.NS", "SAIL.NS", "HINDALCO.NS", "VEDL.NS"],
36
+ "Telecom": ["BHARTIARTL.NS", "RELIANCE.NS", "IDEA.NS"],
37
+ "Oil & Gas": ["RELIANCE.NS", "ONGC.NS", "IOC.NS", "BPCL.NS", "HINDPETRO.NS"],
38
+ "Power": ["NTPC.NS", "POWERGRID.NS", "ADANIGREEN.NS", "TATAPOWER.NS", "CESC.NS"],
39
+ "Real Estate": ["DLF.NS", "GODREJPROP.NS", "OBEROIRLTY.NS", "PHOENIXLTD.NS"],
40
+ "Consumer Electronics": ["DIXON.NS", "VOLTAS.NS", "BLUESTARCO.NS", "HAVELLS.NS"],
41
+ "Insurance": ["SBILIFE.NS", "HDFCLIFE.NS", "ICICIPRULI.NS", "LICI.NS"],
42
+ "Retail": ["DMART.NS", "TRENT.NS", "SHOPPERSSTOP.NS", "VMART.NS"],
43
+ "Paints": ["ASIANPAINT.NS", "BERGEPAINT.NS", "KANSAINER.NS", "INDIGO.NS"],
44
+ }
45
+
46
+ GLOBAL_PEERS: Dict[str, List[str]] = {
47
+ "Technology": ["AAPL", "MSFT", "GOOGL", "META", "AMZN"],
48
+ "Semiconductors": ["NVDA", "AMD", "INTC", "QCOM", "AVGO"],
49
+ "Cloud": ["MSFT", "AMZN", "GOOGL", "ORCL", "CRM"],
50
+ "EV/Auto": ["TSLA", "GM", "F", "TM", "STLA"],
51
+ "Banking": ["JPM", "BAC", "WFC", "C", "GS"],
52
+ "Pharma": ["JNJ", "PFE", "MRK", "ABBV", "LLY"],
53
+ "FMCG": ["PG", "KO", "PEP", "UL", "NESN"],
54
+ "Oil & Gas": ["XOM", "CVX", "SHEL", "BP", "TTE"],
55
+ "Retail": ["WMT", "AMZN", "COST", "TGT", "HD"],
56
+ "Telecom": ["VZ", "T", "TMUS", "VOD", "ORAN"],
57
+ }
58
+
59
+
60
+ def _fmp_peers(ticker: str) -> List[str]:
61
+ """Use FMP's stock-peers endpoint."""
62
+ data = _fmp_get(f"/v4/stock_peers", {"symbol": ticker})
63
+ if data and isinstance(data, list) and data[0].get("peersList"):
64
+ return data[0]["peersList"][:5]
65
+ return []
66
+
67
+
68
+ def select_peers(
69
+ meta: CompanyMeta,
70
+ max_peers: int = 4,
71
+ log_fn=None,
72
+ ) -> List[Dict]:
73
+ """
74
+ Select peer companies for benchmarking.
75
+ Returns list of peer snapshot dicts (same format as peers in the JSON schema).
76
+ """
77
+ def log(msg):
78
+ if log_fn:
79
+ log_fn(msg)
80
+
81
+ ticker = meta.ticker
82
+ peers_out: List[Dict] = []
83
+
84
+ log(f"Selecting peers for {meta.name} ({meta.industry}) …")
85
+
86
+ # 1. Try FMP peers endpoint
87
+ peer_tickers = []
88
+ if FMP_API_KEY and meta.is_listed and ticker != "UNLISTED":
89
+ peer_tickers = _fmp_peers(ticker)
90
+ if peer_tickers:
91
+ log(f" → FMP suggested {len(peer_tickers)} peers")
92
+
93
+ # 2. Fallback to hardcoded industry map
94
+ if not peer_tickers:
95
+ industry = meta.industry or meta.sector or ""
96
+ # Check Indian peers first
97
+ for key, tickers in INDIAN_PEERS.items():
98
+ if key.lower() in industry.lower() or industry.lower() in key.lower():
99
+ peer_tickers = [t for t in tickers if t.upper() != ticker.upper()][:max_peers]
100
+ log(f" → Using hardcoded Indian peers for '{key}'")
101
+ break
102
+ # Check global peers
103
+ if not peer_tickers:
104
+ for key, tickers in GLOBAL_PEERS.items():
105
+ if key.lower() in industry.lower() or industry.lower() in key.lower():
106
+ peer_tickers = [t for t in tickers if t.upper() != ticker.upper()][:max_peers]
107
+ log(f" → Using hardcoded global peers for '{key}'")
108
+ break
109
+
110
+ # Remove self
111
+ peer_tickers = [t for t in peer_tickers if t.upper() != ticker.upper()][:max_peers]
112
+
113
+ if not peer_tickers:
114
+ log(" ⚠ No peers found. Peer comparison section will be skipped.")
115
+ return []
116
+
117
+ # 3. Fetch snapshots for each peer
118
+ for pt in peer_tickers:
119
+ log(f" → Fetching peer data: {pt} …")
120
+ snap = fetch_peer_snapshot(pt)
121
+ if snap:
122
+ peers_out.append(snap)
123
+ log(f" ✓ {snap['name']} ({pt})")
124
+ else:
125
+ log(f" ✗ Could not fetch {pt}")
126
+
127
+ log(f" ✓ {len(peers_out)} peers ready")
128
+ return peers_out
pipeline/resolver.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline/resolver.py — Resolve a company name or ticker to structured metadata.
3
+
4
+ Source priority:
5
+ 1. yfinance (free, no key, excellent Indian stock support)
6
+ 2. FMP API (if key available)
7
+ 3. Mark as unlisted with clear message
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ sys.path.insert(0, str(Path(__file__).parent.parent))
16
+ from config import FMP_API_KEY, FMP_BASE, REQUEST_TIMEOUT
17
+
18
+ import requests
19
+
20
+
21
+ # ── CompanyMeta ───────────────────────────────────────────────────────────────
22
+
23
+ class CompanyMeta:
24
+ def __init__(self, name, ticker, exchange, currency, sector, industry,
25
+ country, description, website, mkt_cap, share_price, is_listed,
26
+ source_note="", fiscal_year_end="March"):
27
+ self.name = name
28
+ self.ticker = ticker
29
+ self.exchange = exchange
30
+ self.currency = currency
31
+ self.sector = sector
32
+ self.industry = industry
33
+ self.country = country
34
+ self.description = description
35
+ self.website = website
36
+ self.mkt_cap = mkt_cap
37
+ self.share_price = share_price
38
+ self.is_listed = is_listed
39
+ self.source_note = source_note
40
+ self.fiscal_year_end = fiscal_year_end
41
+
42
+ def to_dict(self):
43
+ return self.__dict__.copy()
44
+
45
+
46
+ # ── Indian company name → NSE ticker map ─────────────────────────────────────
47
+
48
+ INDIAN_TICKERS = {
49
+ # IT
50
+ "tcs": "TCS.NS", "tata consultancy": "TCS.NS", "tata consultancy services": "TCS.NS",
51
+ "infosys": "INFY.NS", "infy": "INFY.NS",
52
+ "wipro": "WIPRO.NS",
53
+ "hcl": "HCLTECH.NS", "hcl tech": "HCLTECH.NS", "hcl technologies": "HCLTECH.NS",
54
+ "tech mahindra": "TECHM.NS", "techm": "TECHM.NS",
55
+ "ltimindtree": "LTIM.NS", "lti mindtree": "LTIM.NS",
56
+ "mphasis": "MPHASIS.NS",
57
+ "persistent": "PERSISTENT.NS",
58
+ "coforge": "COFORGE.NS",
59
+ # Banking
60
+ "hdfc bank": "HDFCBANK.NS", "hdfcbank": "HDFCBANK.NS",
61
+ "icici bank": "ICICIBANK.NS", "icicibank": "ICICIBANK.NS",
62
+ "axis bank": "AXISBANK.NS", "axisbank": "AXISBANK.NS",
63
+ "sbi": "SBIN.NS", "state bank": "SBIN.NS", "state bank of india": "SBIN.NS",
64
+ "kotak": "KOTAKBANK.NS", "kotak bank": "KOTAKBANK.NS", "kotak mahindra bank": "KOTAKBANK.NS",
65
+ "yes bank": "YESBANK.NS",
66
+ "indusind bank": "INDUSINDBK.NS",
67
+ "bank of baroda": "BANKBARODA.NS",
68
+ "pnb": "PNB.NS", "punjab national bank": "PNB.NS",
69
+ "canara bank": "CANARABANK.NS",
70
+ # Conglomerates / Energy
71
+ "reliance": "RELIANCE.NS", "reliance industries": "RELIANCE.NS", "ril": "RELIANCE.NS",
72
+ "tata": "TATAMOTORS.NS",
73
+ "adani enterprises": "ADANIENT.NS", "adani ports": "ADANIPORTS.NS",
74
+ "adani green": "ADANIGREEN.NS", "adani total gas": "ATGL.NS",
75
+ "ongc": "ONGC.NS",
76
+ "oil and natural gas": "ONGC.NS",
77
+ "ioc": "IOC.NS", "indian oil": "IOC.NS",
78
+ "bpcl": "BPCL.NS", "bharat petroleum": "BPCL.NS",
79
+ "coal india": "COALINDIA.NS",
80
+ # FMCG
81
+ "hindustan unilever": "HINDUNILVR.NS", "hul": "HINDUNILVR.NS",
82
+ "itc": "ITC.NS",
83
+ "nestle india": "NESTLEIND.NS", "nestle": "NESTLEIND.NS",
84
+ "britannia": "BRITANNIA.NS",
85
+ "dabur": "DABUR.NS",
86
+ "marico": "MARICO.NS",
87
+ "godrej consumer": "GODREJCP.NS", "godrej": "GODREJCP.NS",
88
+ "emami": "EMAMILTD.NS",
89
+ # Pharma
90
+ "sun pharma": "SUNPHARMA.NS", "sun pharmaceutical": "SUNPHARMA.NS",
91
+ "cipla": "CIPLA.NS",
92
+ "dr reddy": "DRREDDY.NS", "dr reddys": "DRREDDY.NS",
93
+ "divi's labs": "DIVISLAB.NS", "divis": "DIVISLAB.NS",
94
+ "aurobindo": "AUROPHARMA.NS",
95
+ "lupin": "LUPIN.NS",
96
+ "torrent pharma": "TORNTPHARM.NS",
97
+ # Auto
98
+ "maruti": "MARUTI.NS", "maruti suzuki": "MARUTI.NS",
99
+ "tata motors": "TATAMOTORS.NS",
100
+ "m&m": "M&M.NS", "mahindra": "M&M.NS", "mahindra and mahindra": "M&M.NS",
101
+ "hero motocorp": "HEROMOTOCO.NS", "hero": "HEROMOTOCO.NS",
102
+ "bajaj auto": "BAJAJ-AUTO.NS",
103
+ "eicher": "EICHERMOT.NS", "royal enfield": "EICHERMOT.NS",
104
+ "tvs motor": "TVSMOTOR.NS",
105
+ # Finance (non-bank)
106
+ "bajaj finance": "BAJFINANCE.NS",
107
+ "bajaj finserv": "BAJAJFINSV.NS",
108
+ "hdfc life": "HDFCLIFE.NS",
109
+ "sbi life": "SBILIFE.NS",
110
+ "lic": "LICI.NS",
111
+ "icici prudential": "ICICIPRULI.NS",
112
+ "muthoot": "MUTHOOTFIN.NS",
113
+ # Infra / Utilities
114
+ "larsen": "LT.NS", "l&t": "LT.NS", "larsen and toubro": "LT.NS",
115
+ "ntpc": "NTPC.NS",
116
+ "power grid": "POWERGRID.NS",
117
+ "tata power": "TATAPOWER.NS",
118
+ "adani power": "ADANIPOWER.NS",
119
+ # Cement / Materials
120
+ "ultratech": "ULTRACEMCO.NS", "ultratech cement": "ULTRACEMCO.NS",
121
+ "shree cement": "SHREECEM.NS",
122
+ "ambuja": "AMBUJACEM.NS",
123
+ "acc": "ACC.NS",
124
+ "grasim": "GRASIM.NS",
125
+ "tata steel": "TATASTEEL.NS",
126
+ "jsw steel": "JSWSTEEL.NS",
127
+ "hindalco": "HINDALCO.NS",
128
+ "vedanta": "VEDL.NS",
129
+ "jspl": "JINDALSTEL.NS",
130
+ # Paints / Consumer
131
+ "asian paints": "ASIANPAINT.NS",
132
+ "berger paints": "BERGEPAINT.NS",
133
+ "pidilite": "PIDILITIND.NS",
134
+ "havells": "HAVELLS.NS",
135
+ "voltas": "VOLTAS.NS",
136
+ "titan": "TITAN.NS",
137
+ "tanishq": "TITAN.NS",
138
+ # Retail / Consumer
139
+ "dmart": "DMART.NS", "avenue supermarts": "DMART.NS",
140
+ "trent": "TRENT.NS",
141
+ "zomato": "ZOMATO.NS",
142
+ "nykaa": "NYKAA.NS",
143
+ "policybazaar": "POLICYBZR.NS",
144
+ # Telecom
145
+ "airtel": "BHARTIARTL.NS", "bharti airtel": "BHARTIARTL.NS",
146
+ "jio": "RELIANCE.NS", # Jio is part of Reliance
147
+ # IT Infrastructure
148
+ "hcl infosystems": "HCLISEC.NS",
149
+ "dixon": "DIXON.NS",
150
+ }
151
+
152
+
153
+ def _guess_ticker(query: str) -> Optional[str]:
154
+ """Try to find an Indian NSE ticker for a given name/query."""
155
+ q = query.lower().strip()
156
+ # Exact match
157
+ if q in INDIAN_TICKERS:
158
+ return INDIAN_TICKERS[q]
159
+ # Partial match
160
+ for key, ticker in INDIAN_TICKERS.items():
161
+ if key in q or q in key:
162
+ return ticker
163
+ return None
164
+
165
+
166
+ # ── yfinance resolution ───────────────────────────────────────────────────────
167
+
168
+ def _yf_resolve(ticker: str, log_fn=None) -> Optional[CompanyMeta]:
169
+ """Try to resolve a ticker via yfinance. Returns CompanyMeta or None."""
170
+ def log(msg):
171
+ if log_fn: log_fn(msg)
172
+ try:
173
+ import yfinance as yf
174
+ t = yf.Ticker(ticker)
175
+ info = t.info
176
+ name = info.get("longName") or info.get("shortName", "")
177
+ if not name:
178
+ return None
179
+ exchange_code = info.get("exchange", "")
180
+ currency = info.get("currency", "INR")
181
+ exchange_map = {
182
+ "NSI": "NSE (India)", "BSE": "BSE (India)",
183
+ "NMS": "NASDAQ (USA)", "NYQ": "NYSE (USA)",
184
+ "LSE": "LSE (UK)",
185
+ }
186
+ exchange_display = exchange_map.get(exchange_code, exchange_code or "Unknown")
187
+ country = info.get("country", "India")
188
+ # Fiscal year end
189
+ fy_end = "March" if currency == "INR" else "December"
190
+
191
+ log(f" ✓ yfinance: {name} | {ticker} | {exchange_display}")
192
+ return CompanyMeta(
193
+ name=name,
194
+ ticker=ticker,
195
+ exchange=exchange_display,
196
+ currency=currency,
197
+ sector=info.get("sector", ""),
198
+ industry=info.get("industry", ""),
199
+ country=country,
200
+ description=info.get("longBusinessSummary", ""),
201
+ website=info.get("website", ""),
202
+ mkt_cap=info.get("marketCap"),
203
+ share_price=info.get("currentPrice") or info.get("regularMarketPrice"),
204
+ is_listed=True,
205
+ source_note="Data sourced from Yahoo Finance (yfinance).",
206
+ fiscal_year_end=fy_end,
207
+ )
208
+ except Exception as e:
209
+ log(f" ✗ yfinance failed for {ticker}: {e}")
210
+ return None
211
+
212
+
213
+ # ── FMP resolution ────────────────────────────────────────────────────────────
214
+
215
+ def _fmp_get(path: str, params: dict | None = None):
216
+ if not FMP_API_KEY:
217
+ return None
218
+ url = f"{FMP_BASE}{path}"
219
+ p = {"apikey": FMP_API_KEY}
220
+ if params: p.update(params)
221
+ try:
222
+ r = requests.get(url, params=p, timeout=REQUEST_TIMEOUT)
223
+ r.raise_for_status()
224
+ return r.json()
225
+ except Exception:
226
+ return None
227
+
228
+
229
+ def _fmp_resolve(query: str, log_fn=None) -> Optional[CompanyMeta]:
230
+ """Try FMP search for global companies."""
231
+ def log(msg):
232
+ if log_fn: log_fn(msg)
233
+ if not FMP_API_KEY:
234
+ return None
235
+ log(" → Searching FMP …")
236
+ # Direct profile
237
+ profile = _fmp_get(f"/v3/profile/{query.upper()}")
238
+ if not (profile and isinstance(profile, list) and profile[0].get("symbol")):
239
+ # Search
240
+ results = _fmp_get("/v3/search", {"query": query, "limit": 5})
241
+ if results:
242
+ best = results[0]
243
+ profile = _fmp_get(f"/v3/profile/{best['symbol']}")
244
+ if not (profile and isinstance(profile, list)):
245
+ return None
246
+ p = profile[0]
247
+ if not p.get("companyName"):
248
+ return None
249
+ exchange = p.get("exchange", "") or p.get("exchangeShortName", "")
250
+ exchange_map = {"NSE":"NSE (India)","BSE":"BSE (India)","NASDAQ":"NASDAQ (USA)","NYSE":"NYSE (USA)"}
251
+ log(f" ✓ FMP: {p.get('companyName')} ({p.get('symbol')})")
252
+ return CompanyMeta(
253
+ name=p.get("companyName",""),
254
+ ticker=p.get("symbol",""),
255
+ exchange=exchange_map.get(exchange, exchange),
256
+ currency=p.get("currency","USD"),
257
+ sector=p.get("sector",""),
258
+ industry=p.get("industry",""),
259
+ country=p.get("country",""),
260
+ description=p.get("description",""),
261
+ website=p.get("website",""),
262
+ mkt_cap=p.get("mktCap"),
263
+ share_price=p.get("price"),
264
+ is_listed=True,
265
+ source_note="Data sourced from FMP.",
266
+ fiscal_year_end="December",
267
+ )
268
+
269
+
270
+ # ── yfinance search by company name ──────────────────────────────────────────
271
+
272
+ def _yf_search(query: str, log_fn=None) -> Optional[CompanyMeta]:
273
+ """Use yfinance's search to find a ticker from a company name."""
274
+ def log(msg):
275
+ if log_fn: log_fn(msg)
276
+ try:
277
+ import yfinance as yf
278
+ log(f" → yfinance search for '{query}' …")
279
+ results = yf.Search(query, max_results=5)
280
+ quotes = results.quotes
281
+ if not quotes:
282
+ return None
283
+ # Take the first relevant result
284
+ for q in quotes:
285
+ symbol = q.get("symbol", "")
286
+ qtype = q.get("quoteType", "")
287
+ if qtype in ("EQUITY", "MUTUALFUND") and symbol:
288
+ log(f" → Found: {q.get('longname', symbol)} ({symbol})")
289
+ return _yf_resolve(symbol, log_fn)
290
+ return None
291
+ except Exception as e:
292
+ log(f" ✗ yfinance search failed: {e}")
293
+ return None
294
+
295
+
296
+ # ── Public resolve() ──────────────────────────────────────────────────────────
297
+
298
+ def resolve(query: str, log_fn=None) -> CompanyMeta:
299
+ """
300
+ Resolve a company name or ticker to CompanyMeta.
301
+ Source priority: Indian map → yfinance direct → yfinance search → FMP → unlisted
302
+ """
303
+ def log(msg):
304
+ if log_fn: log_fn(msg)
305
+
306
+ query = query.strip()
307
+ log(f"Resolving: '{query}' …")
308
+
309
+ # 1. Check Indian name map → resolve via yfinance
310
+ indian_ticker = _guess_ticker(query)
311
+ if indian_ticker:
312
+ log(f" → Mapped to Indian ticker: {indian_ticker}")
313
+ meta = _yf_resolve(indian_ticker, log_fn)
314
+ if meta:
315
+ return meta
316
+
317
+ # 2. Try direct yfinance (query as-is, and with .NS suffix)
318
+ for candidate in [query, query.upper(), f"{query.upper()}.NS", f"{query.upper()}.BO"]:
319
+ meta = _yf_resolve(candidate, log_fn)
320
+ if meta:
321
+ return meta
322
+
323
+ # 3. yfinance search
324
+ meta = _yf_search(query, log_fn)
325
+ if meta:
326
+ return meta
327
+
328
+ # 4. FMP search (for global companies)
329
+ meta = _fmp_resolve(query, log_fn)
330
+ if meta:
331
+ return meta
332
+
333
+ # 5. Unlisted fallback
334
+ log(f" ⚠ '{query}' not found in any public market database.")
335
+ log(" → Treating as unlisted / private company.")
336
+ return CompanyMeta(
337
+ name=query, ticker="UNLISTED", exchange="Unlisted / Private",
338
+ currency="INR", sector="Unknown", industry="Unknown",
339
+ country="India", description="", website="",
340
+ mkt_cap=None, share_price=None, is_listed=False,
341
+ source_note=(
342
+ f"'{query}' could not be found. It may be unlisted, private, or use a different name. "
343
+ "Try using the exact BSE/NSE ticker (e.g. RELIANCE, TCS, HDFCBANK)."
344
+ ),
345
+ fiscal_year_end="March",
346
+ )
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ customtkinter>=5.2.0
2
+ requests>=2.31.0
3
+ beautifulsoup4>=4.12.0
4
+ lxml>=4.9.0
5
+ python-dotenv>=1.0.0
6
+ google-generativeai>=0.7.0
7
+ anthropic>=0.25.0
8
+ openpyxl>=3.1.0
9
+ python-docx>=1.1.0
10
+ pandas>=2.0.0
11
+ Pillow>=10.0.0
12
+ yfinance>=0.2.40
storage/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # storage package
storage/cache.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ storage/cache.py — SQLite-backed search history and financial data cache.
3
+ """
4
+ import sqlite3
5
+ import json
6
+ import datetime
7
+ from pathlib import Path
8
+ from typing import Optional, List, Dict, Any
9
+
10
+ import sys
11
+ sys.path.insert(0, str(Path(__file__).parent.parent))
12
+ from config import DB_PATH, CACHE_TTL_DAYS
13
+
14
+
15
+ # ── Database setup ────────────────────────────────────────────────────────────
16
+
17
+ def _connect() -> sqlite3.Connection:
18
+ conn = sqlite3.connect(DB_PATH)
19
+ conn.row_factory = sqlite3.Row
20
+ return conn
21
+
22
+
23
+ def init_db() -> None:
24
+ """Create tables if they don't exist."""
25
+ with _connect() as conn:
26
+ conn.executescript("""
27
+ CREATE TABLE IF NOT EXISTS searches (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ company_name TEXT NOT NULL,
30
+ ticker TEXT,
31
+ exchange TEXT,
32
+ searched_at TEXT NOT NULL,
33
+ models_used TEXT,
34
+ output_docx TEXT,
35
+ output_xlsx TEXT,
36
+ status TEXT DEFAULT 'completed'
37
+ );
38
+
39
+ CREATE TABLE IF NOT EXISTS financial_cache (
40
+ ticker TEXT NOT NULL,
41
+ data_type TEXT NOT NULL,
42
+ fetched_at TEXT NOT NULL,
43
+ expires_at TEXT NOT NULL,
44
+ raw_json TEXT NOT NULL,
45
+ PRIMARY KEY (ticker, data_type)
46
+ );
47
+
48
+ CREATE INDEX IF NOT EXISTS idx_searches_ticker ON searches(ticker);
49
+ CREATE INDEX IF NOT EXISTS idx_cache_ticker ON financial_cache(ticker);
50
+ """)
51
+
52
+
53
+ # ── Search history ────────────────────────────────────────────────────────────
54
+
55
+ def add_search(
56
+ company_name: str,
57
+ ticker: str,
58
+ exchange: str,
59
+ models_used: List[str],
60
+ output_docx: str = "",
61
+ output_xlsx: str = "",
62
+ status: str = "completed",
63
+ ) -> int:
64
+ """Record a completed analysis in search history. Returns row id."""
65
+ now = datetime.datetime.now().isoformat(timespec="seconds")
66
+ with _connect() as conn:
67
+ cur = conn.execute(
68
+ """INSERT INTO searches
69
+ (company_name, ticker, exchange, searched_at, models_used, output_docx, output_xlsx, status)
70
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
71
+ (company_name, ticker, exchange, now, json.dumps(models_used), output_docx, output_xlsx, status),
72
+ )
73
+ return cur.lastrowid
74
+
75
+
76
+ def update_search_outputs(row_id: int, output_docx: str, output_xlsx: str, status: str = "completed") -> None:
77
+ with _connect() as conn:
78
+ conn.execute(
79
+ "UPDATE searches SET output_docx=?, output_xlsx=?, status=? WHERE id=?",
80
+ (output_docx, output_xlsx, status, row_id),
81
+ )
82
+
83
+
84
+ def get_history(limit: int = 50) -> List[Dict]:
85
+ """Return recent searches, newest first."""
86
+ with _connect() as conn:
87
+ rows = conn.execute(
88
+ "SELECT * FROM searches ORDER BY searched_at DESC LIMIT ?", (limit,)
89
+ ).fetchall()
90
+ return [dict(r) for r in rows]
91
+
92
+
93
+ def delete_history_entry(row_id: int) -> None:
94
+ with _connect() as conn:
95
+ conn.execute("DELETE FROM searches WHERE id=?", (row_id,))
96
+
97
+
98
+ # ── Financial data cache ──────────────────────────────────────────────────────
99
+
100
+ def get_cached(ticker: str, data_type: str) -> Optional[Any]:
101
+ """Return cached JSON data if still valid, else None."""
102
+ now = datetime.datetime.now().isoformat()
103
+ with _connect() as conn:
104
+ row = conn.execute(
105
+ "SELECT raw_json, expires_at FROM financial_cache WHERE ticker=? AND data_type=?",
106
+ (ticker.upper(), data_type),
107
+ ).fetchone()
108
+ if row is None:
109
+ return None
110
+ if row["expires_at"] < now:
111
+ # Expired
112
+ return None
113
+ return json.loads(row["raw_json"])
114
+
115
+
116
+ def set_cache(ticker: str, data_type: str, data: Any) -> None:
117
+ """Store data in cache with TTL."""
118
+ now = datetime.datetime.now()
119
+ expires = (now + datetime.timedelta(days=CACHE_TTL_DAYS)).isoformat()
120
+ with _connect() as conn:
121
+ conn.execute(
122
+ """INSERT OR REPLACE INTO financial_cache
123
+ (ticker, data_type, fetched_at, expires_at, raw_json)
124
+ VALUES (?, ?, ?, ?, ?)""",
125
+ (ticker.upper(), data_type, now.isoformat(), expires, json.dumps(data)),
126
+ )
127
+
128
+
129
+ def clear_cache(ticker: str) -> None:
130
+ """Remove all cached data for a ticker (force refresh)."""
131
+ with _connect() as conn:
132
+ conn.execute("DELETE FROM financial_cache WHERE ticker=?", (ticker.upper(),))
133
+
134
+
135
+ def cache_stats() -> Dict:
136
+ """Return some stats for diagnostics."""
137
+ with _connect() as conn:
138
+ total_searches = conn.execute("SELECT COUNT(*) FROM searches").fetchone()[0]
139
+ cached_tickers = conn.execute(
140
+ "SELECT COUNT(DISTINCT ticker) FROM financial_cache"
141
+ ).fetchone()[0]
142
+ return {"total_searches": total_searches, "cached_tickers": cached_tickers}
143
+
144
+
145
+ # Initialise on import
146
+ init_db()
verify_data.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, io
2
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
3
+ sys.path.insert(0, '.')
4
+ from pipeline.resolver import resolve
5
+ from pipeline.fetcher import fetch_all
6
+ from pipeline.calculator import calculate
7
+
8
+ meta = resolve('TCS', log_fn=lambda m: print(m))
9
+ data = fetch_all(meta, years=5, force_refresh=True, log_fn=lambda m: print(m))
10
+ data = calculate(data, meta.to_dict())
11
+
12
+ print()
13
+ print("=== SCALED DATA ===")
14
+ inc = data['income_statement']
15
+ yr = data['years']
16
+ units_label = data.get('units_label', data.get('units', '?'))
17
+ print("Units label:", units_label)
18
+ print()
19
+ for y, r, n, e in zip(yr, inc['revenue'], inc['net_income'], inc['ebitda']):
20
+ r_s = f"{r:,.0f} Cr" if r else "N/A"
21
+ n_s = f"{n:,.0f} Cr" if n else "N/A"
22
+ e_s = f"{e:,.0f} Cr" if e else "N/A"
23
+ print(f" {y} | Rev: {r_s:>15} | NetInc: {n_s:>12} | EBITDA: {e_s:>12}")
24
+
25
+ print()
26
+ rat = data['ratios']
27
+ print("Ratios (latest year):")
28
+ print(f" Operating Margin: {(rat['operating_margin'][-1] or 0)*100:.1f}%")
29
+ print(f" Net Margin: {(rat['net_margin'][-1] or 0)*100:.1f}%")
30
+ print(f" ROE: {(rat['roe'][-1] or 0)*100:.1f}%")
31
+ print(f" Current Ratio: {rat['current_ratio'][-1]}")
32
+ print()
33
+ print("Data looks correct - ready for reports!")
verify_reports.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, io, os
2
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
3
+ sys.path.insert(0, '.')
4
+
5
+ from pipeline.resolver import resolve
6
+ from pipeline.fetcher import fetch_all
7
+ from pipeline.calculator import calculate
8
+ from pipeline.analyst import generate_narrative
9
+ from builders.build_excel import build_workbook
10
+ from builders.build_docx import build_document
11
+
12
+ def run_test():
13
+ print("1. Resolving TCS...")
14
+ meta = resolve('TCS', log_fn=lambda m: print(" " + m))
15
+
16
+ print("\n2. Fetching data...")
17
+ data = fetch_all(meta, years=5, force_refresh=False, log_fn=lambda m: print(" " + m))
18
+
19
+ print("\n3. Calculating ratios...")
20
+ data = calculate(data, meta.to_dict())
21
+
22
+ print("\n4. Generating Narrative (Data-only)...")
23
+ # Use empty models list to skip actual API calls for a quick formatting test
24
+ narrative = generate_narrative(data, meta.to_dict(), models=[], log_fn=lambda m: print(" " + m))
25
+ data.update(narrative)
26
+
27
+ # Save test files
28
+ os.makedirs("test_outputs", exist_ok=True)
29
+ excel_path = "test_outputs/TCS_Analysis.xlsx"
30
+ docx_path = "test_outputs/TCS_Analysis.docx"
31
+
32
+ print("\n5. Building Excel...")
33
+ build_workbook(data, excel_path)
34
+ print(f" ✓ Saved to {excel_path}")
35
+
36
+ print("\n6. Building Word Document...")
37
+ build_document(data, docx_path)
38
+ print(f" ✓ Saved to {docx_path}")
39
+
40
+ print("\nAll tests passed successfully!")
41
+
42
+ if __name__ == "__main__":
43
+ run_test()
web_app/app.py ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ **Groq** *(Llama 3.3 70B — fast & free)*
139
+ <a href='https://console.groq.com/keys' target='_blank' style='font-size:0.75rem;color:#34D399;'>Get free key → console.groq.com</a>
140
+ """, unsafe_allow_html=True)
141
+ groq_key = st.sidebar.text_input(
142
+ "Groq API Key", type="password",
143
+ placeholder="gsk_…",
144
+ value=os.environ.get("GROQ_API_KEY", ""),
145
+ help="100% free. Sign up at console.groq.com → API Keys. No credit card.",
146
+ key="inp_groq"
147
+ )
148
+
149
+ st.sidebar.markdown("""
150
+ **Google Gemini** *(Gemini 2.0 Flash — free tier)*
151
+ <a href='https://aistudio.google.com/app/apikey' target='_blank' style='font-size:0.75rem;color:#34D399;'>Get free key → aistudio.google.com</a>
152
+ """, unsafe_allow_html=True)
153
+ gemini_key = st.sidebar.text_input(
154
+ "Gemini API Key", type="password",
155
+ placeholder="AIza…",
156
+ value=os.environ.get("GEMINI_API_KEY", ""),
157
+ help="Free tier: 15 requests/min, 1M tokens/day. No billing needed.",
158
+ key="inp_gemini"
159
+ )
160
+
161
+ # ── PAID OPTIONS ───────────────────────────────────────────────────────
162
+ st.sidebar.markdown("""
163
+ <div style='background:rgba(99,102,241,0.08);border:1px solid rgba(99,102,241,0.25);
164
+ border-radius:8px;padding:0.6rem 0.85rem;margin:0.5rem 0;'>
165
+ <span style='font-size:0.75rem;font-weight:700;color:#818CF8;letter-spacing:0.06em;'>💳 OPTIONAL — PAID APIS</span>
166
+ </div>
167
+ """, unsafe_allow_html=True)
168
+
169
+ with st.sidebar.expander("Claude (Anthropic) — paid", expanded=False):
170
+ 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)
171
+ claude_key = st.text_input(
172
+ "Claude API Key", type="password",
173
+ placeholder="sk-ant-…",
174
+ value=os.environ.get("CLAUDE_API_KEY", ""),
175
+ key="inp_claude"
176
+ )
177
+
178
+ with st.sidebar.expander("ChatGPT (OpenAI) — paid", expanded=False):
179
+ 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)
180
+ openai_key = st.text_input(
181
+ "OpenAI API Key", type="password",
182
+ placeholder="sk-proj-…",
183
+ value=os.environ.get("OPENAI_API_KEY", ""),
184
+ key="inp_openai"
185
+ )
186
+
187
+ st.sidebar.markdown("---")
188
+ st.sidebar.markdown("<h3 style='color:#34D399;'>👤 User Profile</h3>", unsafe_allow_html=True)
189
+ username = st.sidebar.text_input("Username / Email", value="guest@local", help="Logged in the audit trail")
190
+
191
+ # ── Main Dashboard Layout ──────────────────────────────────────────────
192
+ st.markdown("<h1 class='dashboard-title'>📈 Financial Statement Analyzer</h1>", unsafe_allow_html=True)
193
+ st.markdown("<p class='dashboard-subtitle'>Multi-model AI insights scraper and report compilation portal</p>", unsafe_allow_html=True)
194
+
195
+ # Left Column: Inputs
196
+ # Right Column: Exporters & Progress
197
+ col1, col2 = st.columns([1, 1.2])
198
+
199
+ with col1:
200
+ st.markdown("<div class='glass-card'>", unsafe_allow_html=True)
201
+ st.subheader("Analysis Parameters")
202
+
203
+ ticker_input = st.text_input("Company Name or Stock Ticker", placeholder="e.g. TCS, Reliance, AAPL, MSFT, INFY", help="Resolves directly via yfinance and FMP")
204
+
205
+ col_years, col_refresh = st.columns([1, 1])
206
+ with col_years:
207
+ analysis_years = st.selectbox("Analysis Horizon", options=[5, 7, 10], index=0)
208
+ with col_refresh:
209
+ force_refresh = st.checkbox("Bypass Cache (Force Scrape)", value=False)
210
+
211
+ st.markdown("<b>Download Formats:</b>", unsafe_allow_html=True)
212
+ chk_excel = st.checkbox("Excel Analysis Workbook (.xlsx)", value=True)
213
+ chk_word = st.checkbox("Word Summary Report (.docx)", value=True)
214
+ chk_pptx = st.checkbox("PowerPoint Pitch Deck (.pptx)", value=True)
215
+
216
+ # ── AI Engine selection — auto-disable if key not supplied
217
+ st.markdown("<b>AI Engines:</b>", unsafe_allow_html=True)
218
+
219
+ _hint = "<span style='font-size:0.72rem;color:#9CA3AF;'>(add key in sidebar →)</span>"
220
+
221
+ engines = []
222
+ if st.checkbox(
223
+ "🆓 Groq — Llama 3.3 70B (recommended)",
224
+ value=bool(groq_key),
225
+ disabled=not groq_key,
226
+ help="Free. Get key at console.groq.com"
227
+ ):
228
+ engines.append("groq")
229
+
230
+ if st.checkbox(
231
+ "🆓 Gemini 2.0 Flash (Google)",
232
+ value=bool(gemini_key),
233
+ disabled=not gemini_key,
234
+ help="Free tier. Get key at aistudio.google.com"
235
+ ):
236
+ engines.append("gemini")
237
+
238
+ if st.checkbox(
239
+ "💳 Claude Haiku (Anthropic)",
240
+ value=False,
241
+ disabled=not claude_key,
242
+ help="Paid. Add key in sidebar to enable."
243
+ ):
244
+ engines.append("claude")
245
+
246
+ if st.checkbox(
247
+ "💳 ChatGPT — GPT-4o mini (OpenAI)",
248
+ value=False,
249
+ disabled=not openai_key,
250
+ help="Paid. Add key in sidebar to enable."
251
+ ):
252
+ engines.append("openai")
253
+
254
+ if not groq_key and not gemini_key:
255
+ st.info("💡 Add a **free Groq key** in the sidebar to enable AI narratives.", icon="🔑")
256
+
257
+ st.markdown("<br>", unsafe_allow_html=True)
258
+ start_btn = st.button("Begin Financial Scrape & Analysis")
259
+ st.markdown("</div>", unsafe_allow_html=True)
260
+
261
+ with col2:
262
+ st.markdown("<div class='glass-card'>", unsafe_allow_html=True)
263
+ st.subheader("Execution Status & Logs")
264
+
265
+ log_container = st.empty()
266
+ download_container = st.empty()
267
+
268
+ log_container.code("Ready to analyze. Input a company and click the button above.", language="text")
269
+ st.markdown("</div>", unsafe_allow_html=True)
270
+
271
+ # ── Analysis Runner ───────────────────────────────────────────────────
272
+ if start_btn:
273
+ if not ticker_input:
274
+ st.error("Please enter a stock ticker or company name.")
275
+ elif not (chk_excel or chk_word or chk_pptx):
276
+ st.error("Please select at least one download format checkbox.")
277
+ else:
278
+ # Inject credentials into all modules that cache them at import time
279
+ import config
280
+ import pipeline.analyst
281
+ import pipeline.key_validator
282
+
283
+ config.GEMINI_API_KEY = gemini_key
284
+ config.CLAUDE_API_KEY = claude_key
285
+ config.OPENAI_API_KEY = openai_key
286
+ config.GROQ_API_KEY = groq_key
287
+
288
+ pipeline.analyst.GEMINI_API_KEY = gemini_key
289
+ pipeline.analyst.CLAUDE_API_KEY = claude_key
290
+ pipeline.analyst.OPENAI_API_KEY = openai_key
291
+ pipeline.analyst.GROQ_API_KEY = groq_key
292
+
293
+ pipeline.key_validator.GEMINI_API_KEY = gemini_key
294
+ pipeline.key_validator.CLAUDE_API_KEY = claude_key
295
+ pipeline.key_validator.OPENAI_API_KEY = openai_key
296
+
297
+ # Push into environment for any sub-process
298
+ for k, v in [("GEMINI_API_KEY", gemini_key), ("CLAUDE_API_KEY", claude_key),
299
+ ("OPENAI_API_KEY", openai_key), ("GROQ_API_KEY", groq_key)]:
300
+ if v: os.environ[k] = v
301
+
302
+ # Validate that selected engines have keys
303
+ failed_validation = False
304
+ engine_key_map = {
305
+ "groq": (groq_key, "Groq"),
306
+ "gemini": (gemini_key, "Gemini"),
307
+ "claude": (claude_key, "Claude"),
308
+ "openai": (openai_key, "ChatGPT"),
309
+ }
310
+ for engine in engines:
311
+ key, label = engine_key_map[engine]
312
+ if not key:
313
+ st.error(f"Please supply a {label} API Key or uncheck the {label} model.")
314
+ failed_validation = True
315
+
316
+ if not failed_validation:
317
+ logs = []
318
+
319
+ def log_fn(msg):
320
+ logs.append(msg)
321
+ log_container.code("\n".join(logs), language="text")
322
+
323
+ try:
324
+ log_fn("="*60)
325
+ log_fn(f"Starting analysis for: '{ticker_input}'")
326
+ log_fn(f"Time: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
327
+ log_fn(f"Formats: " + ", ".join([f for f, ok in [("Excel", chk_excel), ("Word", chk_word), ("PowerPoint", chk_pptx)] if ok]))
328
+ log_fn(f"AI Models: {engines or ['None (Data Only)']}")
329
+ log_fn("="*60)
330
+
331
+ # 1. Resolve Company Info
332
+ log_fn("\n[1/7] Resolving company metadata …")
333
+ meta = resolve(ticker_input, log_fn=log_fn)
334
+ log_fn(f" ✓ {meta.name} | {meta.ticker} | {meta.exchange}")
335
+
336
+ # 2. Fetch Statements
337
+ log_fn("\n[2/7] Scrape financial statements …")
338
+ fin_data = fetch_all(meta, force_refresh=force_refresh, log_fn=log_fn)
339
+
340
+ # 3. Peers Comparison Selection
341
+ log_fn("\n[3/7] Benchmarking peer selection …")
342
+ fin_data["peers"] = select_peers(meta, log_fn=log_fn)
343
+
344
+ # 4. Math ratios calculations
345
+ log_fn("\n[4/7] Computing financial ratios & DuPont decomposition …")
346
+ fin_data = calculate(fin_data, meta.to_dict())
347
+ log_fn(" ✓ Completed ratio math computations")
348
+
349
+ # 5. AI Synthesis Narrative
350
+ log_fn("\n[5/7] Requesting AI Model narrative generation …")
351
+ narrative = generate_narrative(fin_data, meta.to_dict(), engines, log_fn=log_fn)
352
+ fin_data.update(narrative)
353
+
354
+ # 6. Generate outputs
355
+ log_fn("\n[6/7] Building output report files …")
356
+ fin_data["company"] = {
357
+ "name": meta.name, "ticker": meta.ticker, "exchange": meta.exchange,
358
+ "currency": fin_data.get("currency", meta.currency),
359
+ "units": fin_data.get("units", "millions"),
360
+ "fiscal_year_end": "",
361
+ "industry": meta.industry, "sector": meta.sector,
362
+ "business_description": meta.description,
363
+ "report_date": datetime.date.today().isoformat(),
364
+ "analysis_period_years": len(fin_data.get("years", [])),
365
+ "share_price": meta.share_price, "market_cap": meta.mkt_cap,
366
+ }
367
+ fin_data["models_used"] = engines
368
+ fin_data.setdefault("sources", [])
369
+
370
+ ticker_safe = meta.ticker.replace(".", "_").replace("/", "_")
371
+ timestamp_suffix = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
372
+
373
+ # Exporters path settings
374
+ excel_bytes = None
375
+ word_bytes = None
376
+ pptx_bytes = None
377
+
378
+ out_dir = PARENT_DIR / "outputs"
379
+ os.makedirs(str(out_dir), exist_ok=True)
380
+
381
+ # Build files in memory / disk
382
+ if chk_excel:
383
+ xlsx_path = out_dir / f"{ticker_safe}_financial_analysis_{timestamp_suffix}.xlsx"
384
+ build_workbook(fin_data, str(xlsx_path))
385
+ with open(xlsx_path, "rb") as f:
386
+ excel_bytes = f.read()
387
+ log_fn(f" ✓ Created Excel analysis: {xlsx_path.name}")
388
+
389
+ if chk_word:
390
+ docx_path = out_dir / f"{ticker_safe}_financial_analysis_{timestamp_suffix}.docx"
391
+ build_document(fin_data, str(docx_path))
392
+ with open(docx_path, "rb") as f:
393
+ word_bytes = f.read()
394
+ log_fn(f" ✓ Created Word summary: {docx_path.name}")
395
+
396
+ if chk_pptx:
397
+ pptx_path = out_dir / f"{ticker_safe}_financial_analysis_{timestamp_suffix}.pptx"
398
+ build_presentation(fin_data, str(pptx_path))
399
+ with open(pptx_path, "rb") as f:
400
+ pptx_bytes = f.read()
401
+ log_fn(f" ✓ Created PowerPoint Presentation: {pptx_path.name}")
402
+
403
+ log_fn(f"\n[7/7] ✅ Financial analysis process complete!")
404
+ log_fn("="*60)
405
+
406
+ # ── Generate download buttons in UI
407
+ with download_container.container():
408
+ st.success("Download ready!")
409
+ col_btn1, col_btn2, col_btn3 = st.columns(3)
410
+
411
+ if excel_bytes:
412
+ col_btn1.download_button(
413
+ label="📥 Download Excel Workbook",
414
+ data=excel_bytes,
415
+ file_name=f"{ticker_safe}_Analysis_{timestamp_suffix}.xlsx",
416
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
417
+ )
418
+ if word_bytes:
419
+ col_btn2.download_button(
420
+ label="📥 Download Word Report",
421
+ data=word_bytes,
422
+ file_name=f"{ticker_safe}_Report_{timestamp_suffix}.docx",
423
+ mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document"
424
+ )
425
+ if pptx_bytes:
426
+ col_btn3.download_button(
427
+ label="📥 Download PowerPoint Deck",
428
+ data=pptx_bytes,
429
+ file_name=f"{ticker_safe}_Presentation_{timestamp_suffix}.pptx",
430
+ mime="application/vnd.openxmlformats-officedocument.presentationml.presentation"
431
+ )
432
+
433
+ # Write search activity log to audit table
434
+ selected_formats = [f for f, ok in [("xlsx", chk_excel), ("docx", chk_word), ("pptx", chk_pptx)] if ok]
435
+ save_audit_log(
436
+ user_email=username,
437
+ company_ticker=meta.ticker,
438
+ company_name=meta.name,
439
+ selected_formats=selected_formats,
440
+ ai_models=engines,
441
+ status="completed"
442
+ )
443
+
444
+ except Exception as e:
445
+ log_fn(f"\n❌ Pipeline failed: {e}")
446
+ import traceback
447
+ log_fn(traceback.format_exc())
448
+ st.error(f"Analysis failed: {e}")
449
+
450
+ # Save audit log failure
451
+ try:
452
+ selected_formats = [f for f, ok in [("xlsx", chk_excel), ("docx", chk_word), ("pptx", chk_pptx)] if ok]
453
+ save_audit_log(
454
+ user_email=username,
455
+ company_ticker=ticker_input,
456
+ company_name="N/A",
457
+ selected_formats=selected_formats,
458
+ ai_models=engines,
459
+ status="failed"
460
+ )
461
+ except:
462
+ pass
463
+
464
+ # ── Audit History Panel Table ───────────────────────────────────────────
465
+ st.markdown("---")
466
+ st.markdown("<h3 style='color:#60A5FA;margin-top:0;'>📜 Query Audit Trail & History</h3>", unsafe_allow_html=True)
467
+ st.markdown("<p style='font-size:0.85rem;color:#9CA3AF;'>Past analysis runs requested by team members.</p>", unsafe_allow_html=True)
468
+
469
+ history_logs = get_audit_logs()
470
+ if history_logs:
471
+ st.table(history_logs)
472
+ else:
473
+ st.info("No queries have been run yet.")
web_app/database.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ web_app/database.py — Database audit logging system.
3
+
4
+ Supports dual-driver data persistence:
5
+ - Direct Cloud Supabase PostgreSQL storage (if environment variables exist).
6
+ - Automatic Local SQLite database fallback.
7
+ """
8
+ import os
9
+ import sqlite3
10
+ import datetime
11
+ from pathlib import Path
12
+ from typing import List, Dict
13
+
14
+ DB_PATH = Path(__file__).parent / "financial_audit.db"
15
+
16
+ def init_db():
17
+ """Initializes the local SQLite database if not present."""
18
+ conn = sqlite3.connect(str(DB_PATH))
19
+ cursor = conn.cursor()
20
+ cursor.execute("""
21
+ CREATE TABLE IF NOT EXISTS financial_analysis_audit (
22
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
23
+ timestamp TEXT NOT NULL,
24
+ user_email TEXT NOT NULL,
25
+ company_ticker TEXT NOT NULL,
26
+ company_name TEXT NOT NULL,
27
+ selected_formats TEXT NOT NULL,
28
+ ai_model_used TEXT NOT NULL,
29
+ status TEXT NOT NULL
30
+ )
31
+ """)
32
+ conn.commit()
33
+ conn.close()
34
+
35
+ def save_audit_log(user_email: str, company_ticker: str, company_name: str, selected_formats: List[str], ai_models: List[str], status: str):
36
+ """
37
+ Saves an audit log entry.
38
+ Tries Supabase first, falls back to local SQLite on failure or if not configured.
39
+ """
40
+ timestamp = datetime.datetime.utcnow().isoformat()
41
+ selected_formats_str = ", ".join(selected_formats)
42
+ ai_models_str = ", ".join(ai_models) if ai_models else "none"
43
+
44
+ supabase_url = os.environ.get("SUPABASE_URL")
45
+ supabase_key = os.environ.get("SUPABASE_KEY")
46
+
47
+ if supabase_url and supabase_key:
48
+ try:
49
+ from supabase import create_client, Client
50
+ supabase: Client = create_client(supabase_url, supabase_key)
51
+ data = {
52
+ "user_email": user_email,
53
+ "company_ticker": company_ticker,
54
+ "company_name": company_name,
55
+ "selected_formats": selected_formats, # stored as text array in Postgres
56
+ "ai_model_used": ai_models_str,
57
+ "status": status
58
+ }
59
+ supabase.table("financial_analysis_audit").insert(data).execute()
60
+ print("Audit log successfully written to Cloud Supabase DB.")
61
+ return
62
+ except Exception as e:
63
+ print(f"Supabase logging failed: {e}. Falling back to SQLite.")
64
+
65
+ # Local SQLite Fallback
66
+ try:
67
+ init_db()
68
+ conn = sqlite3.connect(str(DB_PATH))
69
+ cursor = conn.cursor()
70
+ cursor.execute("""
71
+ INSERT INTO financial_analysis_audit (timestamp, user_email, company_ticker, company_name, selected_formats, ai_model_used, status)
72
+ VALUES (?, ?, ?, ?, ?, ?, ?)
73
+ """, (timestamp, user_email, company_ticker, company_name, selected_formats_str, ai_models_str, status))
74
+ conn.commit()
75
+ conn.close()
76
+ print("Audit log written to local SQLite database.")
77
+ except Exception as sqle:
78
+ print(f"Local SQLite database audit write error: {sqle}")
79
+
80
+ def get_audit_logs() -> List[Dict]:
81
+ """
82
+ Fetches the history of audit logs.
83
+ Tries Supabase first, falls back to local SQLite.
84
+ """
85
+ supabase_url = os.environ.get("SUPABASE_URL")
86
+ supabase_key = os.environ.get("SUPABASE_KEY")
87
+
88
+ if supabase_url and supabase_key:
89
+ try:
90
+ from supabase import create_client, Client
91
+ supabase: Client = create_client(supabase_url, supabase_key)
92
+ response = supabase.table("financial_analysis_audit").select("*").order("timestamp", desc=True).limit(50).execute()
93
+ logs = []
94
+ for row in response.data:
95
+ # Convert list array from supabase to comma-separated string for display alignment
96
+ formats = row.get("selected_formats", [])
97
+ formats_str = ", ".join(formats) if isinstance(formats, list) else str(formats)
98
+
99
+ # Format timestamp
100
+ ts = row.get("timestamp", "")
101
+ if ts:
102
+ try:
103
+ ts = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00")).strftime("%Y-%m-%d %H:%M:%S")
104
+ except:
105
+ pass
106
+
107
+ logs.append({
108
+ "timestamp": ts,
109
+ "user_email": row.get("user_email", "N/A"),
110
+ "company_ticker": row.get("company_ticker", "N/A"),
111
+ "company_name": row.get("company_name", "N/A"),
112
+ "selected_formats": formats_str,
113
+ "ai_model_used": row.get("ai_model_used", "N/A"),
114
+ "status": row.get("status", "N/A")
115
+ })
116
+ return logs
117
+ except Exception as e:
118
+ print(f"Supabase fetch logs failed: {e}. Falling back to SQLite.")
119
+
120
+ # SQLite Fallback
121
+ try:
122
+ init_db()
123
+ conn = sqlite3.connect(str(DB_PATH))
124
+ conn.row_factory = sqlite3.Row
125
+ cursor = conn.cursor()
126
+ cursor.execute("SELECT * FROM financial_analysis_audit ORDER BY timestamp DESC LIMIT 50")
127
+ rows = cursor.fetchall()
128
+ logs = []
129
+ for r in rows:
130
+ ts = r["timestamp"]
131
+ if ts and "T" in ts:
132
+ try:
133
+ ts = datetime.datetime.fromisoformat(ts).strftime("%Y-%m-%d %H:%M:%S")
134
+ except:
135
+ pass
136
+ logs.append({
137
+ "timestamp": ts,
138
+ "user_email": r["user_email"],
139
+ "company_ticker": r["company_ticker"],
140
+ "company_name": r["company_name"],
141
+ "selected_formats": r["selected_formats"],
142
+ "ai_model_used": r["ai_model_used"],
143
+ "status": r["status"]
144
+ })
145
+ conn.close()
146
+ return logs
147
+ except Exception as sqle:
148
+ print(f"Local SQLite logs retrieve error: {sqle}")
149
+ return []
web_app/requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ streamlit>=1.30.0
2
+ python-pptx>=0.6.21
3
+ supabase==2.4.0
4
+ requests>=2.31.0
5
+ beautifulsoup4>=4.12.0
6
+ lxml>=4.9.0
7
+ python-dotenv>=1.0.0
8
+ google-generativeai>=0.7.0
9
+ google-genai>=0.8.0
10
+ anthropic>=0.25.0
11
+ openai>=1.0.0
12
+ openpyxl>=3.1.0
13
+ python-docx>=1.1.0
14
+ pandas>=2.0.0
15
+ Pillow>=10.0.0
16
+ yfinance>=0.2.40
web_app/run_web.bat ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ title Financial Analyzer Web Portal Launcher
3
+ cd /d "%~dp0"
4
+
5
+ echo ====================================================
6
+ echo Starting Financial Statement Analysis Web Portal...
7
+ echo ====================================================
8
+
9
+ :: Path check for virtual environments
10
+ set ROOT_VENV=..\..\.venv
11
+ set LOCAL_VENV=.venv
12
+
13
+ if exist "%ROOT_VENV%\Scripts\python.exe" (
14
+ echo [INFO] Found shared virtual environment at %ROOT_VENV%
15
+ set VENV_PATH=%ROOT_VENV%
16
+ ) else if exist "%LOCAL_VENV%\Scripts\python.exe" (
17
+ echo [INFO] Found local virtual environment at %LOCAL_VENV%
18
+ set VENV_PATH=%LOCAL_VENV%
19
+ ) else (
20
+ echo [INFO] Creating new virtual environment...
21
+ python -m venv .venv
22
+ set VENV_PATH=.venv
23
+ )
24
+
25
+ set PYTHON_EXE=%VENV_PATH%\Scripts\python.exe
26
+ set PIP_EXE=%VENV_PATH%\Scripts\pip.exe
27
+ set STREAMLIT_EXE=%VENV_PATH%\Scripts\streamlit.exe
28
+
29
+ echo [INFO] Updating Python dependencies inside virtual environment...
30
+ %PIP_EXE% install -r requirements.txt
31
+ if %ERRORLEVEL% neq 0 (
32
+ echo [ERROR] Dependencies installation failed.
33
+ pause
34
+ exit /b 1
35
+ )
36
+
37
+ echo [INFO] Booting Streamlit server...
38
+ %STREAMLIT_EXE% run app.py
39
+ if %ERRORLEVEL% neq 0 (
40
+ echo [ERROR] Streamlit failed to start.
41
+ pause
42
+ exit /b 1
43
+ )
44
+
45
+ pause
web_app/streamlit_config.toml ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [general]
2
+ email = ""
3
+
4
+ [server]
5
+ port = 7860
6
+ address = "0.0.0.0"
7
+ headless = true
8
+ enableXsrfProtection = false
9
+ maxUploadSize = 50
10
+
11
+ [browser]
12
+ gatherUsageStats = false
13
+ serverAddress = "0.0.0.0"
14
+ serverPort = 7860
15
+
16
+ [theme]
17
+ base = "dark"
18
+ backgroundColor = "#0A0F1D"
19
+ secondaryBackgroundColor = "#0F162A"
20
+ textColor = "#F3F4F6"
21
+ primaryColor = "#3B82F6"