Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .gitignore +38 -0
- Dockerfile +39 -0
- README.md +59 -5
- builders/__init__.py +1 -0
- builders/build_docx.py +379 -0
- builders/build_excel.py +446 -0
- builders/build_pptx.py +423 -0
- config.py +41 -0
- main.py +42 -0
- pipeline/__init__.py +1 -0
- pipeline/analyst.py +579 -0
- pipeline/calculator.py +361 -0
- pipeline/fetcher.py +542 -0
- pipeline/google_login.py +182 -0
- pipeline/key_validator.py +160 -0
- pipeline/peer_selector.py +128 -0
- pipeline/resolver.py +346 -0
- requirements.txt +12 -0
- storage/__init__.py +1 -0
- storage/cache.py +146 -0
- verify_data.py +33 -0
- verify_reports.py +43 -0
- web_app/app.py +473 -0
- web_app/database.py +149 -0
- web_app/requirements.txt +16 -0
- web_app/run_web.bat +45 -0
- web_app/streamlit_config.toml +21 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|