diff --git a/.gitignore b/.gitignore index 497251c..2dd5367 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ venv/ .env .app_prefs.json -data/ +# SEC filing cache files (auto-generated, do not commit) +data/*.json +data/*.html __pycache__/ .DS_Store diff --git a/README.md b/README.md index 80284db..73ba1a4 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,192 @@ -# All-in-One Financial Analysis Dashboard +# ATLAS Terminal — All-in-One Financial Analysis Dashboard -A **cost-effective** Streamlit app that unifies **qualitative AI-driven insights** and **quantitative valuation** in a single workflow. **Hybrid architecture:** Gemini powers narrative analysis (10-K MD&A and Risk Factors); all numbers—DCF inputs and peer multiples—come from **yfinance**, keeping API costs low and numerical accuracy high. +A **cost-effective**, institutional-grade financial analysis platform built with Streamlit. Combines **qualitative AI-driven insights** from SEC 10-K filings with **quantitative valuation models** in a single unified workflow. -The app is organised into **eight tabs:** - -| Tab | Purpose | -|-----|---------| -| **1. 10-K & MD&A Insights** | SEC EDGAR 10-K → Item 1A + Item 7 → cleaned text → Gemini. DuPont, Altman Z, red flags, YoY; Sankey, Radar, 5Y financials, KPI. | -| **2. Market Heatmap** | Sector and macro heatmaps; rates, oil, VIX and related indicators. | -| **3. Valuation Hub (DCF / RIM)** | 10-year 2-stage DCF, Reverse DCF, RIM. Bull/Base/Bear; ticker-currency display and USD conversion when non-USD. | -| **4. Industry Analysis & Comps** | Peer comps (P/E, EV/EBITDA, P/B); conditional formatting; **Generate Industry Outlook** (Gemini) for macro trends. | -| **5. SEC Filings (Raw)** | SEC filing list and links to full documents. | -| **6. Earnings & Estimates** | Consensus, Beat/Miss chart, analyst targets and recommendations; ticker-currency and USD conversion. | -| **7. Portfolio & Watchlist** | Holdings with **per-position currency** (USD/GBP/EUR/KRW/JPY/CNY), **fractional quantity**, AI screenshot import (Gemini Vision), **FX-adjusted returns**. | -| **8. Crypto** | Bithumb (KRW) and Binance (USD) live prices. | +**Hybrid architecture:** Google Gemini powers qualitative narrative analysis (MD&A, Risk Factors); all numbers—DCF inputs, peer multiples, technical indicators—come from **yfinance** and **yahooquery**, keeping API costs low and numerical accuracy high. --- -## Features +## Live Demo -- **Tab 1 — 10-K & MD&A:** Item 1A + Item 7; DuPont, Altman Z, red flags, YoY; sector/industry badge; sector-specific metrics (Tech/Retail/Financials); Gemini comparative MD&A with sector-aware Non-GAAP KPI table. TTM fallback and N/A handling when yfinance rows are missing. -- **Tab 2 — Market Heatmap:** Sector and macro heatmaps; key indicators. -- **Tab 3 — Valuation Hub:** 10-year 2-stage DCF, Reverse DCF, RIM. Smart defaults (Beta/CAPM WACC, terminal growth). Reference panel: analyst consensus and Damodaran sector WACC/ERP/Rf. **Ticker-currency display** and **USD conversion** for non-USD names (`format_price_with_usd`). -- **Tab 4 — Sector Analysis:** Predefined sectors with peer tickers; comps table; green/red formatting; AI Industry Outlook (Gemini). -- **Tab 5 — SEC Filings:** Raw filing list and links. -- **Tab 6 — Earnings & Estimates:** Consensus, Beat/Miss chart, analyst targets; **ticker currency** and USD conversion. -- **Tab 7 — Portfolio & Watchlist:** **Per-position Currency** (USD, GBP, EUR, KRW, JPY, CNY). **Fractional quantity** (e.g. 30.395107). **AI screenshot import** (Gemini Vision): extracts Ticker, Average Price, Currency (from $, £, €, ₩, ¥), Quantity (decimals preserved). **FX-adjusted returns:** user cost in local currency → real-time FX → `adjusted_avg` in asset currency → **Total Return %** = (current − adjusted_avg) / adjusted_avg. `get_fx_rate`, `get_fx_rate_to_usd` (yfinance FX, TTL 60–120s). -- **Tab 8 — Crypto:** Bithumb KRW, Binance USD. -- **App-wide multi-currency:** `get_currency_for_ticker` (auto-detect trading currency); `format_price_with_usd` (local + USD equivalent, e.g. ₩ 181,200 (≈ $ 132.50)). -- **Error handling:** Try/except for SEC EDGAR, yfinance, and Gemini; clear messages and optional manual overrides. -- **UI:** Sidebar (API key, SEC email, **global company search** via yahooquery—search by name in any language). **Quantitative charts** (Sankey, Radar, F-Score) can use SEC 10-K Item 8 + Gemini extraction (US) or yahooquery/yfinance (global tickers with auto suffix). +``` +streamlit run app.py --server.port 8501 +``` +Open: [http://localhost:8501](http://localhost:8501) -**Run time:** Tab 1 ≈ 1–2 min (one Gemini call); other tabs use yfinance (seconds). Rate limit: 60s retry. +--- + +## Seven-Tab Layout + +| Tab | Purpose | +|-----|---------| +| **1. 10-K & MD&A Insights** | SEC EDGAR 10-K → Item 7 (MD&A) + Item 1A (Risk Factors) → Gemini streaming analysis. DuPont, Altman Z-Score, red flags, YoY ratios; Piotroski F-Score; sector-specific KPIs; Sankey & Radar charts. Native SEC/DART filing HTML viewer. | +| **2. DCF Valuation** | 5-year 2-stage DCF with Bull/Base/Bear scenarios. Smart defaults from Beta/CAPM. Damodaran sector WACC reference panel. Analyst consensus, FCFF/FCFE bridge, sensitivity table. | +| **3. Industry Comps** | Peer multiples (Forward P/E, EV/EBITDA, P/B) with green/red conditional formatting. Gemini-powered industry outlook (12–18 month macro trends). | +| **4. News Feed** | Real-time Google News RSS feed filtered by company. | +| **5. Markets & FX** | Live FX rates (USD/KRW, GBP/USD, EUR/USD, USD/JPY). S&P 500 sector performance heatmap (XLK, XLV, XLF …). | +| **6. Crypto** | Live prices for 12 major cryptocurrencies (BTC, ETH, SOL, XRP …) with 24h change and market cap. | +| **7. Technical & Risk** | RSI(14), SMA(50/200), Golden/Death Cross signals, 52-week range, support/resistance. Quantitative risk matrix with estimated EPS impact per risk factor. | + +--- + +## Key Features + +### AI & Qualitative Analysis (Tab 1) +- **Gemini streaming** for Item 7 (Management Strategy) and Item 1A (Risk Factors) — results appear word-by-word in real time +- **Forensic audit** (Item 3 & 9A) runs automatically alongside Risk Factor analysis +- **Native SEC Filing Viewer**: renders original SEC HTML directly in-app via `streamlit.components.v1.html()` — no redirect, no loss of formatting +- **Filing type selector**: 10-K, 10-Q, 8-K, 20-F, 6-K — backend dynamically fetches the correct form from EDGAR +- **Korean DART direct links** for Korean-listed companies +- **Sector-aware Non-GAAP KPI extraction**: Gemini identifies industry-specific metrics (ARR/NDR for SaaS, Same-Store Sales for Retail, Rule of 40 for Tech) + +### Quantitative Analysis (Tab 1 & 2) +- **DuPont decomposition** (3-step ROE: NPM × Asset Turnover × Equity Multiplier) +- **Altman Z-Score** (Safe > 2.99, Grey Zone 1.81–2.99, Distress < 1.81) +- **Piotroski F-Score** (9-point checklist; SEC Item 8 + Gemini for US equities, yahooquery/yfinance globally) +- **Sankey chart**: Income Statement flow (Revenue → COGS → Gross → OpEx → EBIT → Tax/Interest → Net Income) +- **Radar chart**: 5-axis financial health (Profitability, Liquidity, Efficiency, Solvency, Growth) +- **YoY and QoQ ratio changes** with coloured trend indicators +- **Sector-specific metrics**: Tech (Rule of 40, R&D %), Retail (Inventory Turnover), Financials (ROE, ROA) + +### DCF & Valuation (Tab 2) +- **Excel-style 5-year DCF**: 3 scenarios (Bull/Base/Bear) with probability-weighted expected return +- **Smart defaults**: WACC from CAPM (Beta), terminal growth 2.5% (Damodaran-style), FCF growth from consensus estimates +- **Damodaran sector WACC reference panel**: Software 8.5%, Retail 7.5%, Hardware 9.0%, Financials 8.0% +- **FCFF/FCFE bridge**: detailed waterfall from EBIT → NOPAT → FCFF and Net Income → FCFE +- **DCF sensitivity table**: 5×5 grid across WACC and terminal growth rate combinations +- **Analyst consensus** embedded next to sliders (target price, recommendation, revenue/earnings growth estimates) + +### Data Robustness +- **Primary**: yahooquery for fundamentals + TTM construction +- **Fallback**: yfinance (multi-step: `fast_info` → `info` → balance sheet) +- **TTM fallback**: quarterly sum when annual data is unavailable +- **PyArrow-safe DataFrames**: uniform column types to prevent serialization errors +- **`@st.cache_data` caching**: 2–60 min TTL per function to minimise API calls + +### Global Company Search +- Search by name in **any language** (English, Korean, Japanese, etc.) via yahooquery +- Auto-infers market suffix: `.KS`/`.KQ` (Korea), `.T` (Japan), `.L` (UK) +- Last selected company **persists across page refresh** via local `.app_prefs.json` + +--- + +## Architecture: Hybrid AI + Quantitative Pipeline + +``` +SEC EDGAR (10-K HTML) + │ + ├── Item 7 (MD&A) ──────► Gemini 2.0 Flash ─► Strategy / Sentiment + ├── Item 1A (Risk) ──────► Gemini 2.0 Flash ─► Risk Factors + Forensic Audit + └── Item 8 (Financials) ─► (NOT sent to LLM — avoids hallucination on numbers) + +yfinance / yahooquery + │ + ├── Income / Balance / Cashflow ─► DuPont, Piotroski, Altman Z + ├── Price history ───────────────► RSI, SMA, 52W Range + └── DCF inputs (FCF, Debt, Cash, Shares) ─► Bull/Base/Bear intrinsic value +``` + +**Design principle:** LLM for text only; Python for numbers. This eliminates hallucination risk on financial figures and keeps API costs to a single Gemini call per session. + +--- + +## Modular Code Architecture (v3.0) + +The codebase was refactored from a 3,909-line monolith into **28 focused modules**, each under 300 lines, following strict Separation of Concerns. + +``` +app.py # Thin orchestrator (~118 lines) +│ +├── config/ +│ ├── constants.py # Company lists, sector maps, row maps, Damodaran baselines +│ └── theme.py # Soft Navy CSS theme + header HTML +│ +├── utils/ +│ ├── prefs.py # Local preference persistence (.app_prefs.json) +│ ├── formatting.py # _safe_float, _format_shares_display, _na +│ ├── ticker.py # get_global_ticker, infer_market_from_ticker +│ ├── dcf.py # excel_style_dcf, dcf_10y_2stage, _damodaran_wacc_for_sector +│ ├── charts.py # Sankey, Radar (Plotly) builders +│ └── ui_helpers.py # Analyst consensus panel, DCF sensitivity table +│ +├── data/ +│ ├── sec_parser.py # HTML text extraction, Item section finder (regex) +│ ├── sec_fetcher.py # EDGAR API fetch (CIK lookup, submissions, HTML cache) +│ ├── sec_downloader.py # 10-K download via sec-edgar-downloader, section extraction +│ ├── financials.py # yahooquery + yfinance annual data, TTM construction +│ ├── fundamentals.py # Sector/industry, 5-year trend, DCF inputs +│ ├── valuation.py # Analyst consensus, DCF smart defaults, FCFF/FCFE +│ ├── ratios.py # Comps, DuPont/Altman Z, quarterly momentum/ratios +│ ├── scores.py # Sankey data, radar metrics, Piotroski, sector metrics +│ ├── scores_ai.py # AI-derived Sankey/Piotroski/Radar from Gemini extraction +│ └── market.py # Technical indicators, risk matrix, ticker bar, news RSS +│ +├── ai/ +│ ├── gemini_core.py # Model init, retry logic, streaming, chunking, forensic audit +│ ├── gemini_sec.py # SEC financials LLM, Item 7 strategy stream, Item 1A risk stream +│ └── gemini_insights.py # MDA chunked insights, comparative analysis, industry outlook +│ +└── views/ + ├── sidebar.py # Company search, API keys, market selector + ├── tab1_quant.py # Financial health tables & charts + ├── tab1_ai.py # Deep-dive AI streaming analysis + ├── tab1_filings.py # SEC/DART native filing HTML viewer + ├── tab2_dcf.py # DCF valuation & FCFF/FCFE + ├── tab3_comps.py # Industry comps & AI outlook + ├── tab4_news.py # News RSS feed + ├── tab5_markets.py # FX rates & sector heatmap + ├── tab6_crypto.py # Cryptocurrency prices + └── tab7_technical.py # Technical indicators & risk matrix +``` + +**Dependency direction (no circular imports):** +``` +app.py → views/ → data/ or ai/ +utils/ ← importable from anywhere +data/ ↔ ai/ direct imports are forbidden +``` + +--- + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| UI Framework | Streamlit | +| AI / LLM | Google Gemini 2.0 Flash (`google-generativeai`) | +| Financial Data | yahooquery (primary), yfinance (fallback) | +| SEC Data | sec-edgar-downloader, EDGAR public REST API | +| HTML Parsing | BeautifulSoup4, lxml | +| Charts | Plotly (Sankey, Scatterpolar Radar, Line) | +| Caching | `@st.cache_data` (2–60 min TTL per function) | + +--- + +## Technical Challenges & Solutions + +### Challenge 1 — 429 Resource Exhausted (LLM Token Overflow) + +**Problem:** Full 10-K filings (200+ pages) caused Gemini 429 errors and rate limits. + +**Solution:** Selective section extraction (Item 7 only → ~80% token reduction), HTML cleansing (BeautifulSoup + regex strips tags/whitespace), smart chunking with head+tail trim, and a 60-second retry decorator. + +### Challenge 2 — SEC EDGAR HTML Not Rendering + +**Problem:** The filing viewer showed "원본 HTML을 가져오지 못했습니다" because the legacy code used `directory.item` from the index JSON (now deprecated) instead of the submissions API. + +**Solution:** Rebuilt the EDGAR fetch chain — `company_tickers.json` → CIK lookup → `submissions/CIK{cik}.json` → `filings.recent.primaryDocument[]` → direct `.htm` download. Added `streamlit.components.v1.html()` for native in-app rendering with an injected CSS reset. + +### Challenge 3 — PyArrow Serialization in Streamlit + +**Problem:** Mixed-type DataFrame columns (float + string in same column) caused `ArrowInvalid` errors when passing DataFrames through `@st.cache_data`. + +**Solution:** Explicitly coerce all display strings before DataFrame construction; keep numeric columns as float, string columns as str throughout the pipeline. + +### Challenge 4 — 3,909-line Monolith Maintainability + +**Problem:** A single `app.py` containing all business logic, UI rendering, and data fetching became unmanageable and untestable. + +**Solution:** Full modular refactoring into 28 files across 5 packages (config, utils, data, ai, views). Dependency graph enforced no circular imports. All cache decorators and session state preserved identically. Each file kept under 300 lines. --- @@ -39,182 +194,89 @@ The app is organised into **eight tabs:** ### The Origin — The Walk -The core idea for this all-in-one architecture came during a **quiet walk**. I was deep in thought about the inefficiencies and fragmentation of traditional equity research: narrative buried in 200-page filings, valuation models in separate spreadsheets, and comps scattered across different tools. It became clear that what we need is not more dashboards, but **one seamless workflow**—where qualitative AI insights and quantitative valuation models live in the same place, speak the same language, and serve the same decision. That moment crystallised into the design you see here: **unified, cost-conscious, and built for the analyst who thinks in both words and numbers.** +The core idea came during a **quiet walk** while reflecting on the fragmentation of traditional equity research: narratives buried in 200-page filings, valuation models in separate spreadsheets, and comp tables scattered across different tools. What analysts need is not more dashboards — but **one seamless workflow** where qualitative AI insights and quantitative valuation models live in the same place, speak the same language, and serve the same decision. -### The Vision — Commercialization +That realisation crystallised into the design you see here: **unified, cost-conscious, and built for the analyst who thinks in both words and numbers.** -This repository is a **functional MVP (Minimum Viable Product)** and **demo**. It proves the concept: hybrid architecture works; 10-K + DCF + comps can sit in a single interface; and the unit economics (one Gemini call for narrative, free data for the rest) scale. The code is production-minded but not yet productised—it is the foundation on which a commercial product will be built. +### The Vision — Commercialisation -### Future Roadmap +This repository is a **functional MVP** and technical portfolio piece. It proves the concept: hybrid architecture works, 10-K + DCF + comps can coexist in a single interface, and the unit economics (one Gemini call for narrative, free data for the rest) scale sustainably. The modular codebase is production-minded — each module under 300 lines, no circular imports, explicit error handling — and is the foundation on which a commercial product will be built. -The **ultimate goal** is to launch this as a **fully commercialised B2C/B2B SaaS** application. We aim to serve **retail investors** who want institutional-grade structure without the complexity, and **finance professionals** (equity analysts, portfolio managers, corporate development) who want to move from filing → insight → valuation in one flow. Data-driven, transparent, and built by someone who cares as much about the quality of the analysis as the quality of the code. This project is the first step on that path. +**Ultimate goal:** Launch as a **fully commercialised B2C/B2B SaaS** serving retail investors who want institutional-grade structure without complexity, and finance professionals (equity analysts, portfolio managers, corporate development) who want to move from filing → insight → valuation in one flow. --- -## Design Rationale & Interview Notes +## Design Rationale (Interview Notes) -*(Why certain features were built the way they were — useful for interviews and discussions.)* +- **Why hybrid (LLM for text, Python for numbers)?** + LLMs hallucinate financial figures. Separating concerns — Gemini for narrative, yfinance for numbers — gives the best of both: nuanced qualitative analysis with numerically accurate, auditable quantitative data. -- **Undergraduate automation mindset** - As an undergraduate student, I realised that rather than just learning Excel and basic Python and doing everything manually, **automating the full workflow with AI and programmatic data** is far more powerful. This dashboard is the result: one place for 10-K narrative (Gemini), numbers (yfinance), DCF, and comps, so the analyst can focus on judgment instead of copy-pasting between tools. +- **Why a 5-year 2-stage DCF instead of a simple Gordon Growth model?** + A single-stage model lets terminal value dominate the result, which overstates value for high-growth companies. The 2-stage model (Stage 1: projected FCF growth; Stage 2: terminal growth) is closer to how institutional DCF models are built and avoids absurd valuations. -- **Why a 10-year DCF instead of 5 years** - A standard 5-year projection is often **too short for practical, real-world corporate analysis**. Many companies have growth that extends beyond five years, and terminal value then dominates the result, which can overstate or misstate value. The **10-year 2-stage model** (Stage 1: Years 1–5 at the chosen FCF growth rate; Stage 2: Years 6–10 with growth **linearly fading** down to the terminal growth rate) is closer to how institutional DCFs are built and avoids absurd valuations for high-growth names. +- **Why integrate Damodaran's academic baselines?** + Slider defaults anchored to peer-reviewed data (Damodaran sector WACC, US ERP, 10Y risk-free rate) give users a credible starting point. The reference panel links to his data pages so users can verify and critique the assumptions. -- **Integrating Damodaran's academic baselines** - I regularly read valuation literature and **wanted to integrate Aswath Damodaran's academic baselines directly into the app**. The "Reference: Analyst & Macro Assumptions" panel shows sector WACC benchmarks (e.g. Software 8.5%, Retail 7.5%, Hardware 9.0%, Financials 8.0%), US equity risk premium (~4.6%), and the 10-year risk-free rate (~4.2%), with a link to his data and methodology so users can verify and align their assumptions with established research. +- **Why modular architecture?** + Single-file Streamlit apps are fast to prototype but impossible to test, maintain, or extend. Separation of concerns — config, utils, data, ai, views — makes each component independently comprehensible, testable, and replaceable without touching the rest of the system. -- **Consensus numbers next to the DCF sliders** - Having **analyst consensus data (target price, recommendation, revenue/earnings growth) right next to the DCF sliders** makes it much easier to make informed adjustments. Instead of guessing WACC or growth, the user can compare their inputs to both consensus and Damodaran's macro baselines in one view, like a professional equity research dashboard. - -- **Commercialization** - Once the app's **completeness and robustness reach a higher professional standard**, my ultimate goal is to **fully commercialise it** (e.g. B2C/B2B SaaS). The current codebase is built as a production-minded MVP and demo to validate the hybrid architecture and user flow before scaling. +- **Why yahooquery as primary (not yfinance)?** + yahooquery's bulk query API returns TTM-constructed financials with cleaner column names. yfinance is kept as a fallback for tickers yahooquery misses and for technical/historical price data. --- -## Tech Stack - -- **UI**: Streamlit -- **Data**: sec-edgar-downloader (SEC EDGAR), **yahooquery** (search + fundamentals), **yfinance** (prices, FX, fallback) -- **AI**: Google Gemini (google-generativeai) -- **Parsing / cleansing**: BeautifulSoup, lxml, regex - ---- - -## Technical Challenge: Handling Large-Scale Financial Filings - -During the initial development of the SEC analysis module, I encountered severe 429 Resource Exhausted errors and extreme latency. The massive size of raw 10-K filings (often exceeding 100k+ tokens) easily breached the LLM’s context window and rate limits. - -Consultation & Architectural Pivot: -After consulting with a my friend who is junior software engineer working at MUST Company, I recognised that feeding entire financial documents to an LLM is an anti-pattern. I re-architected the application to a highly optimised Hybrid Data Pipeline, strictly decoupling qualitative reasoning from quantitative data retrieval. - -Implemented Solutions: - -Decoupled Processing (Hybrid Architecture): > * Qualitative (Gemini AI): Strictly limited to processing Item 7 (MD&A) for strategic insights, risk assessment, and sentiment analysis. - -Quantitative (yfinance API): Hard numbers (Revenue, Net Income, OCF) are fetched directly via API. This guarantees 100% deterministic accuracy for financials and prevents the LLM from hallucinating numbers or wasting tokens on dense HTML tables. - -Targeted Extraction & Fallback Logic: Engineered a robust Regex-based parser to isolate only Item 7 from SEC EDGAR documents. Implemented safe fallback mechanisms to prevent app crashes when encountering unconventional document structures. - -DOM Traversal & Noise Reduction: Before sending the extracted text to Gemini, a preprocessing pipeline (using BeautifulSoup + Regex) strips away HTML tags, inline CSS, repetitive boilerplate, and page numbers, drastically compressing the token footprint. - -Context Window Optimization: For exceptionally long MD&A sections, I implemented a Head-Tail Truncation chunking strategy—retaining the executive introduction and concluding remarks—to ensure the most semantically dense information stays within token limits. - -In-Memory Caching: Applied Streamlit caching (@st.cache_data) for both parsed SEC documents and LLM responses, eliminating redundant API calls and ensuring instant load times for subsequent queries. - -Results & Efficiency: -This architectural shift reduced the token payload by roughly [80]%, completely resolved the 429 errors, dropped rendering latency to under [5] seconds, and achieved zero API cost for fundamental financial data retrieval. - -(For full technical notes, code snippets, and architecture diagrams, see TECHNICAL_NOTES.md.) - ## Requirements -- Python 3.9+ -- [Google API Key (Gemini)](https://aistudio.google.com/apikey) -- An email address for SEC EDGAR (required for programmatic access) -- **plotly** and **yfinance** (for Market Heatmap and FX momentum charts; `pip install plotly yfinance` if missing) -- Optional: `.env` with `GOOGLE_API_KEY` and `SEC_EDGAR_EMAIL` +- Python 3.9+ +- [Google API Key (Gemini)](https://aistudio.google.com/apikey) +- An email address for SEC EDGAR programmatic access +- All Python dependencies in `requirements.txt` +- Optional: `.env` with `GOOGLE_API_KEY` and `SEC_EDGAR_EMAIL` --- ## How to Run -**1. Go to the project folder** ```bash +# 1. Navigate to project directory cd "/path/to/your/FQDC Project" -``` -*(Replace with your actual project path.)* -**2. Activate the virtual environment** (required so `pip` and `streamlit` are found) -- **Mac / Linux:** - ```bash - source venv/bin/activate - ``` -- **Windows (PowerShell):** - ```powershell - venv\Scripts\Activate.ps1 - ``` -After activation, your prompt usually shows `(venv)`. +# 2. Activate virtual environment +source venv/bin/activate # Mac/Linux +# venv\Scripts\Activate.ps1 # Windows PowerShell -**3. Install dependencies** (only needed once, or when requirements change) -```bash +# 3. Install dependencies (first time or when requirements change) pip install -r requirements.txt -``` -**4. Start the app** -```bash +# 4. Launch the app streamlit run app.py --server.port 8501 -``` -또는 `./run.sh` (포트 8501 고정) - -**접속 주소 (이것만 사용):** http://localhost:8501 -*(다른 포트(8502, 8510 등)로 떠 있는 창은 닫고 위 주소 하나만 사용하세요.)* - -If you don't have a `venv` folder yet, create it first: -```bash -python3 -m venv venv -source venv/bin/activate # then steps 3 and 4 +# or: ./run.sh ``` -Open the sidebar to set **Google API Key** and **SEC EDGAR Email**, then use the eight tabs (10-K Insights, Heatmap, DCF, Comps, SEC Filings, Earnings, Portfolio, Crypto) as needed. +Open **http://localhost:8501** in your browser. + +Set **Google API Key** and **SEC EDGAR Email** in the sidebar. Then search for any company by name (any language) and explore the seven tabs. --- -## Project Structure +## Update History (Changelog) -``` -├── app.py # Streamlit app (Gemini, hybrid flow) -├── find_toc.py # Standalone script: find Table of Contents from SEC EDGAR HTML URL -├── requirements.txt # Python dependencies -├── .env.example # Example env vars (copy to .env) -├── README.md # This file -└── TECHNICAL_NOTES.md # Technical challenge & solution (for reference) -``` - ---- - -## Update history (Changelog) - -Updates are listed in **reverse chronological order (newest first)**. Each row summarises **what** was added and **why** (where relevant). - -| Date (UTC) | Updates | -|------------|---------| -| **2026-02-18** | **Market Heatmap & FX charts, 10-K 한글/영문:** (1) **Global Markets (Tab 2):** Sector heatmap uses **5d/1mo** data and **per-ticker fallback** so the treemap always renders (weekend/holiday robust). **FX Momentum** section now includes a **normalized 1Y line chart** (GBP/USD, EUR/USD, USD/JPY, KRW) with per-pair fetch fallback. **plotly** and **yfinance** fallback import in the view so charts work after `pip install plotly yfinance` without restart. (2) **10-K & MD&A:** Display language **한글 / 영문** radio; when "한글" is selected, Strategy and Risk analysis results are translated via Gemini (`translate_report_with_gemini`, 24h cache). Gemini API key required for translation. (3) **Requirements:** `plotly` and `yfinance` are required for heatmap and FX charts; install with `pip install plotly yfinance` if missing. | -| **2026-02-17** | **DART, prefs, 10-K tab, run script:** DART fetch timeout 90s; DART report titles in English (cached). SEC & DART tab: per-category iframe viewer (Annual/Quarterly/Other). **Last selected company** persisted in `.app_prefs.json` so it survives page refresh. API key input field no longer masked (type=password removed). Global Markets: time period labels in English; macro/FX use 5d when 1D selected for sparklines. **10-K & MD&A** tab restored in Deep Analysis with Item 7 (Strategy) and Item 1A (Risk) Gemini streaming. Single run script: **`run.sh`** and README state **http://localhost:8501** only. | -| **2025-02-15** | **Multi-currency portfolio & app-wide FX:** (1) **Portfolio (Tab 7):** Per-position **Currency** column (USD, GBP, EUR, KRW, JPY, CNY). **Fractional quantity** support (e.g. 30.395107). **FX-adjusted returns:** user cost/currency → real-time `get_fx_rate(user_curr, stock_curr)` → `adjusted_avg` in asset currency → Total Return % = (current − adjusted_avg) / adjusted_avg. (2) **AI screenshot (Gemini Vision):** Prompt updated to extract **Currency** (from $, £, €, ₩, ¥ → USD/GBP/EUR/KRW/JPY) and **Quantity** with decimals preserved. (3) **App-wide:** `get_currency_for_ticker`, `get_fx_rate`, `get_fx_rate_to_usd` (yfinance FX, TTL 60–120s), `format_price_with_usd` (local + USD e.g. ₩ 181,200 (≈ $ 132.50)). (4) **Valuation Hub:** DCF/Reverse DCF/RIM show ticker currency and USD conversion when non-USD. (5) **Earnings & Estimates:** Analyst targets/price in ticker currency with USD conversion. README restored to English with full changelog and 8-tab layout. | -| **2025-02-14** | **Global company search & README:** Sidebar company search replaced with yahooquery `search()`: type company name (e.g. Samsung, 삼성, Mitsubishi), click "Search Company", select from dropdown `[Exchange] Symbol - Name`. Filter: EQUITY/ETF only (exclude INDEX/MUTUALFUND). Market suffix inferred from symbol (.KS/.KQ, .T, .L). README: Tech Stack (yahooquery, lxml), Features (global search, Item 8 quant), run/push instructions path-agnostic. | -| **2025-02-13** | **Design Rationale & README:** New section "Design Rationale & Interview Notes" (undergrad automation mindset, 10y DCF rationale, Damodaran integration, consensus-panel rationale, commercialization). README Features and tab table updated to reflect 10Y 2-stage DCF, sector analysis, and Wall Street Assumptions panel. Changelog expanded with more detailed entries. | -| **2025-02-13** | **Institutional DCF & Wall Street panel:** (1) **10-year 2-stage DCF:** Stage 1 (Y1–5) at user FCF growth; Stage 2 (Y6–10) linear fade from that rate to terminal growth (avoids absurd valuations for high-growth stocks). TV at Year 10; all FCFs + TV discounted to PV. (2) **Wall Street Assumptions panel** (expander below sliders): **Left column** — Analyst consensus from yfinance: target mean price, recommendation, revenue growth est., earnings growth est. (N/A if missing). **Right column** — Damodaran macro baseline: sector WACC map (Software 8.5%, Retail 7.5%, Hardware 9.0%, Financials 8.0%, etc.), US ERP ~4.6%, 10Y risk-free ~4.2%, plus markdown link to his WACC data page for methodology. Company sector matched via `get_sector_industry` for Damodaran WACC. | -| **2025-02-13** | **Smart DCF defaults:** Slider defaults no longer hardcoded. **WACC:** CAPM approximation using `ticker.info.get('beta')` (default 1.0), Risk-free 4%, MRP 5%; default WACC = 4 + Beta×5, rounded to 1 decimal. **Terminal growth:** Fixed at 2.5% (Damodaran-style, long-term US GDP). **FCF growth:** From `revenueGrowth` or `earningsGrowth` (e.g. 0.15 → 15%); fallback 8%. Caption above sliders: "Slider defaults are auto-generated based on the company's Beta (CAPM) and revenue growth estimates." | -| **2025-02-13** | **Robust DCF data & comps:** (1) **Shares/Debt/Cash:** Multi-step fallback (fast_info → info → balance sheet) so S&P 500 names rarely need manual input. Shares: `fast_info.shares` → `sharesOutstanding` → `impliedSharesOutstanding`; display as "X.XXB Shares (real-time, auto-fetched)". Manual number_input only when all sources fail. (2) **Tab 3 redesign — Top-down sector analysis:** Manual ticker input removed. `SECTORS` dict (e.g. Semiconductors, Software & Cloud, Consumer Retail, Financials, Healthcare) with top 5 tickers each; st.selectbox to choose industry; comps table auto-loads with spinner. yfinance keys fixed to `forwardPE`, `enterpriseToEbitda`, `priceToBook`; missing shown as N/A. Conditional formatting: lowest P/E and EV/EBITDA green, highest red. **Generate Industry Outlook** button: Gemini prompt for macro analyst-style report (12–18 month trends, growth drivers, headwinds/regulatory risks); report rendered in Markdown below table. | -| **2025-02-13** | **Bulletproof DCF & Excel-style logic:** DCF no longer fails when yfinance misses data. Base FCF = OCF − CapEx; if Shares/Debt/Cash missing, st.number_input fallbacks. Three sliders (WACC, Terminal Growth, Projected FCF Growth) drive full DCF; intrinsic value vs current price (from yfinance) and Bull/Base/Bear table. Tab 1: Interest Coverage "nan%" fixed (N/A when Interest Expense 0 or missing). | -| **2025-02-12 15:30** | **Dynamic Sector-Specific Analysis:** DuPont table None/NaN → "N/A". Sector & industry badge (Tab 1). Sector-specific metrics: Tech (Rule of 40, FCF margin, R&D % revenue), Retail (inventory turnover, operating margin), Financials (ROE, ROA). Tab 2 caption for Financials (FCF/EBITDA less relevant). Gemini MD&A: sector/industry passed in; prompt asks for industry-specific Non-GAAP KPIs in a markdown table. | -| **2025-02-12 11:00** | **Data robustness & TTM fallback:** `_get_row_series` try/except; `_na(x)` for display. TTM fallback when annual financials/balance_sheet missing (quarterly sum / latest quarter). `get_sector_industry(ticker)` added. DuPont/Altman return empty dict on exception. | -| **2025-02-12 09:00** | **Hybrid architecture:** Item 7 only to Gemini; yfinance for numbers. HTML cleansing, find_toc.py. Prompt: strategy, risks, sentiment. | -| **2025-02-12 08:45** | Changelog and find_toc.py in Project Structure. | -| **2025-02-12** | **Remember API key & email:** Optional "Remember API key & email (save locally)" checkbox; values stored in `.app_prefs.json` (in .gitignore); prefill on load; uncheck removes file. | -| **2025-02-12** | **S&P 500 sample expander removed** from sidebar (user request). | -| **2025-02-01** (approx.) | Item 7 & 8 selective extraction; 429 retry 60s; Gemini, GOOGLE_API_KEY; CFA report, metrics table. | -| **2025-01-XX** (approx.) | Initial release: SEC EDGAR 10-K, Item 7 & 8, LLM analysis, Streamlit UI, S&P 500 sample list. | - ---- - -## Push to GitHub - -From the project folder, commit and push (run in your terminal so authentication works): - -```bash -cd "/path/to/your/FQDC Project" -git add README.md app.py requirements.txt -git status -git commit -m "README: restore English, full changelog; add 2025-02-15 multi-currency portfolio & FX" -git push origin main -``` - -If you use another branch or remote: replace `main` or `origin`. New repo: `git init`, then `git remote add origin `. +| Date | Update | +|------|--------| +| **2026-03-19** | **Modular refactoring (v3.0) + SEC filing viewer fix:** (1) **Architecture:** 3,909-line `app.py` refactored into 28 focused modules across `config/`, `utils/`, `data/`, `ai/`, `views/`. Each file under 300 lines. Strict unidirectional dependency graph (no circular imports). All `@st.cache_data` TTLs and `st.session_state` keys preserved identically. (2) **SEC Filing Viewer fixed:** Rebuilt EDGAR fetch chain using `submissions/CIK{cik}.json` → `filings.recent.primaryDocument[]` (replaces deprecated `directory.item` lookup). Added filing type `st.selectbox` (10-K, 10-Q, 8-K, 20-F, 6-K) connected to backend dynamically. Native HTML rendered via `streamlit.components.v1.html()` with injected CSS reset. Errors surfaced explicitly with `st.error()`. (3) **DART links** restored for Korean-listed companies. | +| **2026-02-18** | **Market Heatmap & FX charts:** Sector heatmap with 5d/1mo data and per-ticker fallback (weekend/holiday robust). FX Momentum normalized 1Y line chart (GBP/USD, EUR/USD, USD/JPY, KRW). 10-K language toggle (한글/영문) via Gemini translation. plotly/yfinance added to requirements. | +| **2026-02-17** | **DART, prefs, run script:** DART fetch timeout 90s; DART report titles in English (cached). SEC & DART per-category iframe viewer. Last selected company persisted in `.app_prefs.json` (survives page refresh). Single run script `run.sh` at port 8501. | +| **2025-02-15** | **Multi-currency portfolio & FX:** Per-position currency (USD/GBP/EUR/KRW/JPY/CNY), fractional quantity, FX-adjusted returns. Gemini Vision AI screenshot import (extracts ticker, price, currency, quantity). App-wide `get_currency_for_ticker`, `get_fx_rate`, `format_price_with_usd`. | +| **2025-02-14** | **Global company search:** yahooquery `search()` replaces static dropdown. Search by name in any language; filters INDEX/MUTUALFUND; auto-infers .KS/.KQ/.T/.L suffix. | +| **2025-02-13** | **Design Rationale & 10Y DCF:** Design rationale section (undergrad automation mindset, 10Y 2-stage DCF, Damodaran integration). Wall Street Assumptions panel (analyst consensus + Damodaran baselines). Smart DCF defaults from Beta/CAPM. | +| **2025-02-13** | **Robust data & comps redesign:** Multi-step shares/debt/cash fallback (fast_info → info → balance). Top-down sector analysis with `SECTORS` dict and AI Industry Outlook (Gemini). | +| **2025-02-12** | **Hybrid architecture:** Item 7 only to Gemini; yfinance for all numbers. HTML cleansing pipeline (BeautifulSoup + regex). | +| **2025-02-12** | **DuPont, Altman Z, Piotroski, sector KPIs, TTM fallback:** Full quantitative financial health suite. Sector-specific metrics (Tech: Rule of 40; Retail: Inventory Turnover; Financials: ROE/ROA). | +| **2025-02-12** | **Preference persistence:** "Remember API key & email" checkbox; `.app_prefs.json` (gitignored). | +| **2025-01-XX** | **Initial release:** SEC EDGAR 10-K download, Item 7/8 extraction, Gemini analysis, Streamlit UI. | --- ## License and Disclaimer -This project is for learning and portfolio use. Comply with [SEC policy](https://www.sec.gov/os/webmaster-faq#code-support) when using SEC data and with Google's terms for the Gemini API. +This project is built for learning, research, and portfolio demonstration. Comply with [SEC EDGAR policy](https://www.sec.gov/os/webmaster-faq#code-support) when accessing SEC data, and with Google's terms of service for the Gemini API. Nothing in this app constitutes investment advice. diff --git a/ai/__init__.py b/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai/gemini_core.py b/ai/gemini_core.py new file mode 100644 index 0000000..6ec4987 --- /dev/null +++ b/ai/gemini_core.py @@ -0,0 +1,113 @@ +""" +Gemini model initialization, retry logic, streaming, text chunking, summarize/synthesize/forensic. +""" +import re +import time +from config.constants import GEMINI_MODEL, RATE_LIMIT_WAIT_SEC + + +def get_gemini_model(api_key: str): + import google.generativeai as genai + genai.configure(api_key=api_key) + return genai.GenerativeModel(GEMINI_MODEL) + + +def _is_rate_limit_error(e: Exception) -> bool: + err_msg = str(e).lower() + return "429" in err_msg or "resourcelimited" in err_msg or "resource exhausted" in err_msg or getattr(e, "code", None) == 429 + + +def _generate_with_retry(model, content, config, max_retries: int = 3): + last_err = None + for attempt in range(max_retries + 1): + try: + return model.generate_content(content, generation_config=config) + except Exception as e: + last_err = e + if attempt < max_retries and _is_rate_limit_error(e): + time.sleep(RATE_LIMIT_WAIT_SEC) + continue + raise + raise last_err + + +def _generate_stream(model, content, config): + """Yield text chunks from Gemini with stream=True. For use with st.write_stream().""" + try: + response = model.generate_content(content, generation_config=config, stream=True) + for chunk in response: + if hasattr(chunk, "text") and chunk.text: + yield chunk.text + except Exception: + raise + + +def _split_into_chunks(text: str, max_chars: int = 22000, min_chunk: int = 5000) -> list: + """Split text into sequential chunks without cutting mid-sentence when possible.""" + if not text or len(text) <= max_chars: + return [text] if text and text.strip() else [] + chunks = [] + start = 0 + while start < len(text): + end = min(start + max_chars, len(text)) + if end < len(text): + break_at = text.rfind("\n\n", start, end + 1) + if break_at > start + min_chunk: + end = break_at + 2 + chunks.append(text[start:end].strip()) + start = end + return [c for c in chunks if c] + + +def _gemini_summarize_segment(api_key: str, segment_text: str, ticker: str, segment_label: str) -> str: + """Extract strategic shifts and hidden risks from one segment. No trimming.""" + model = get_gemini_model(api_key) + prompt = f"""You are a senior equity analyst. The following is one segment of the 10-K for {ticker} (Item 1A Risk Factors and/or Item 7 MD&A). +Extract and list all significant: (1) strategic shifts or priorities, (2) hidden or material risks, (3) management tone cues. Use concise bullet points. Do not omit important details. Segment: {segment_label}.""" + full = f"""--- 10-K Segment ---\n\n{segment_text[:50000]}\n\n---\n\n{prompt}""" + try: + r = _generate_with_retry(model, full, {"temperature": 0.2, "max_output_tokens": 2048}) + return (r.text or "").strip() + except Exception: + return "" + + +def _gemini_synthesize_report(api_key: str, segment_summaries: list, ticker: str, sector: str, industry: str) -> str: + """Synthesis call: turn segment summaries into Executive Insight Report.""" + model = get_gemini_model(api_key) + combined = "\n\n---\n\n".join(segment_summaries) + kpi_note = f" Sector: {sector}; Industry: {industry}. Include industry-specific KPIs if mentioned." if sector and sector != "N/A" else "" + prompt = f"""You are a senior equity analyst. Use British English. Below are summarized insights from the full 10-K for {ticker} (Item 1A and Item 7). Create the final **Executive Insight Report** with these sections: + +1. **Management's Tone (Sentiment)**: Overall tone and supporting evidence. +2. **Current Strategy & Priorities**: Key strategic focus, capital allocation, growth drivers. +3. **Major Hidden Risks**: The 3-4 most material risks investors might overlook. +4. **Forensic / Quality of Earnings**: Accounting caveats, one-offs, cash flow vs earnings. If none material, say so briefly.{kpi_note} + +Use clear headings. Do not invent figures. Keep under 900 words.""" + full = f"""--- Segment Summaries ---\n\n{combined}\n\n---\n\n{prompt}""" + try: + r = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 4096}) + return (r.text or "").strip() + except Exception: + return "" + + +def _gemini_forensic_audit(api_key: str, item3: str, item9a: str, ticker: str) -> str: + """Dedicated high-priority check: Material Weaknesses, lawsuits, off-balance-sheet from Item 3 and 9A.""" + model = get_gemini_model(api_key) + combined = (item3 or "") + "\n\n---\n\n" + (item9a or "") + if not combined.strip(): + return "No Item 3 / 9A text provided; skip forensic." + prompt = f"""From the following 10-K excerpts for {ticker} (Item 3 Legal Proceedings and Item 9A Controls/Internal Control), list any: +- Material weaknesses in internal control +- Significant legal proceedings or litigation +- Off-balance-sheet or governance red flags +If none of the above, output exactly: "No material red flags or special issues detected in Item 3 and 9A." +Be concise (under 150 words).""" + full = f"""--- Item 3 & 9A ---\n\n{combined[:30000]}\n\n---\n\n{prompt}""" + try: + r = _generate_with_retry(model, full, {"temperature": 0.1, "max_output_tokens": 512}) + return (r.text or "").strip() + except Exception: + return "" diff --git a/ai/gemini_insights.py b/ai/gemini_insights.py new file mode 100644 index 0000000..0f85ea6 --- /dev/null +++ b/ai/gemini_insights.py @@ -0,0 +1,191 @@ +from typing import Optional +import streamlit as st +from data.sec_parser import smart_chunk, clean_text_for_llm +from ai.gemini_core import ( + get_gemini_model, _generate_with_retry, _is_rate_limit_error, + _split_into_chunks, _gemini_summarize_segment, _gemini_synthesize_report, _gemini_forensic_audit, +) + + +def get_mda_chunked_insights( + api_key: str, sections: dict, ticker: str, sector: str, industry: str, progress_callback=None +) -> str: + """Full-text analysis: chunk 1A+7, summarize each segment, synthesize report; then append forensic (Item 3, 9A). progress_callback(step: str) optional.""" + def _progress(msg): + if progress_callback: + progress_callback(msg) + combined = (sections.get("item1a") or "") + "\n\n---\n\n" + (sections.get("item7") or "") + combined = combined.strip() + if not combined: + return "No 10-K text available to analyse." + chunks = _split_into_chunks(combined, max_chars=22000) + if not chunks: + return "No content extracted." + summaries = [] + n = len(chunks) + for i, ch in enumerate(chunks): + _progress(f"Analyzing Segment {i+1}/{n}...") + summary = _gemini_summarize_segment(api_key, ch, ticker, f"Segment {i+1}/{n}") + if summary: + summaries.append(summary) + if not summaries: + return "Segment analysis produced no summaries." + _progress("Synthesizing final report...") + report = _gemini_synthesize_report(api_key, summaries, ticker, sector or "N/A", industry or "N/A") + _progress("Running forensic audit (Item 3 & 9A)...") + forensic = _gemini_forensic_audit(api_key, sections.get("item3") or "", sections.get("item9a") or "", ticker) + return (report or "") + "\n\n---\n\n**Forensic (Item 3 & 9A)**\n\n" + (forensic or "") + + +def get_mda_insights(api_key: str, item1a_text: str, item7_text: str, ticker: str) -> str: + """Send Item 1A + Item 7 to Gemini. Analyse: 1) Management's Tone (Sentiment), 2) Key Strategic Shifts, 3) Major Hidden Risks.""" + model = get_gemini_model(api_key) + combined = [] + if item1a_text: + combined.append(clean_text_for_llm(item1a_text)) + if item7_text: + combined.append(clean_text_for_llm(item7_text)) + combined_text = "\n\n---\n\n".join(combined) + combined_text = smart_chunk(combined_text, max_chars=22000) + + user_prompt = f"""You are a senior equity analyst. Use British English. + +The text below is from the 10-K for {ticker}: **Item 1A (Risk Factors)** and **Item 7 (Management's Discussion and Analysis)**. HTML has been stripped; analyse only the substance. + +Provide a concise report with three sections: + +1. **Management's Tone (Sentiment)**: Is the overall tone positive, cautious, or negative? Quote 1–2 short phrases that support your view. + +2. **Key Strategic Shifts**: What strategic priorities or shifts does management emphasise (e.g. capital allocation, growth drivers, new segments)? Be specific. + +3. **Major Hidden Risks**: From both Risk Factors and MD&A, what are the 3–4 most material risks that an investor might overlook? Cite the document. + +Use clear headings. Do not invent figures. Keep the response focused and under 800 words.""" + + full_content = f"""--- 10-K Excerpt (Item 1A + Item 7) ---\n\n{combined_text}\n\n---\n\n{user_prompt}""" + + try: + response = _generate_with_retry( + model, full_content, {"temperature": 0.3, "max_output_tokens": 4096} + ) + except Exception as api_err: + if _is_rate_limit_error(api_err): + raise RuntimeError("Rate limit exceeded. Please try again in a few minutes.") from api_err + raise + if not response or not response.text: + return "No analysis generated." + return response.text.strip() + + +def get_mda_comparative_insights( + api_key: str, + item1a_text: str, + item7_latest: str, + item7_3y_ago: Optional[str], + ticker: str, + sector: Optional[str] = None, + industry: Optional[str] = None, +) -> str: + """Comparative analysis: if item7_3y_ago provided, compare MD&As over 3 years; else single-year. Sector-aware: extract industry-specific Non-GAAP KPIs.""" + model = get_gemini_model(api_key) + sector_label = (sector or "N/A").strip() + industry_label = (industry or "N/A").strip() + kpi_instruction = ( + f" Given that this company is in the **{sector_label}** sector" + + (f" (industry: {industry_label})" if industry_label != "N/A" else "") + + ", meticulously scan the MD&A to find and extract **industry-specific Non-GAAP KPIs** " + "(e.g. Same-Store Sales Growth for Retail, ARR/NDR for Software, DAU/MAU for Tech). Present these hidden KPIs in a **clean markdown table** with columns such as KPI name, value, and period if stated." + ) + if not item7_3y_ago or not item7_3y_ago.strip(): + combined = [] + if item1a_text: + combined.append(clean_text_for_llm(item1a_text)) + if item7_latest: + combined.append(clean_text_for_llm(item7_latest)) + combined_text = "\n\n---\n\n".join(combined) + combined_text = smart_chunk(combined_text, max_chars=22000) + user_prompt = f"""You are a senior equity analyst. Use British English. +The text below is from the **latest 10-K only** for {ticker}: **Item 1A (Risk Factors)** and **Item 7 (MD&A)**. Provide a focused deep-dive report: + +1. **Management's Tone (Sentiment)**: Overall tone and 1–2 supporting phrases. +2. **Current Strategy & Priorities**: Key strategic focus, capital allocation, growth drivers from this filing only. +3. **Major Hidden Risks**: From Item 1A and MD&A, the 3–4 most material risks investors might overlook. +4. **Forensic / Quality of Earnings**: Any red flags in MD&A (accounting caveats, one-offs, cash flow vs earnings, segment disclosure). If none material, say so briefly.{kpi_instruction} +**Token-saving (Item 3 / 9A):** If no material weaknesses, major lawsuits, or off-balance-sheet red flags, output exactly: "\u2705 No material red flags or special issues detected in Item 3 and 9A." +Use clear headings. Under 800 words.""" + full_content = f"""--- 10-K Excerpt (Latest Year) ---\n\n{combined_text}\n\n---\n\n{user_prompt}""" + else: + latest_clean = smart_chunk(clean_text_for_llm(item7_latest), max_chars=12000) + past_clean = smart_chunk(clean_text_for_llm(item7_3y_ago), max_chars=12000) + user_prompt = f"""You are a senior equity analyst. Use British English. +Below are **Item 7 (Management's Discussion and Analysis)** from the 10-K for {ticker}: **LATEST YEAR** and **THREE YEARS AGO**. Perform a **Comparative Analysis**. + +1. **Core strategy**: What has changed in the company's stated strategy, priorities, or capital allocation between then and now? +2. **Emerging risks**: What new risks appear in the latest MD&A that were absent or less prominent 3 years ago? +3. **Management's tone**: How has the overall tone (confidence, caution, optimism) shifted? Quote 1–2 phrases from each period if relevant. +4. **Industry-specific KPIs**:{kpi_instruction} +5. **Item 3 (Legal) & Item 9A (Internal Controls):** You must save output tokens. If there are no material weaknesses, no massive lawsuits, and no major off-balance sheet red flags, DO NOT generate a long explanation. Simply output exactly: "\u2705 No material red flags or special issues detected in Item 3 and 9A." and move on. + +Use clear headings. Do not invent figures. Keep the response focused and under 900 words.""" + full_content = f"""--- MD&A LATEST YEAR ---\n\n{latest_clean}\n\n--- MD&A THREE YEARS AGO ---\n\n{past_clean}\n\n---\n\n{user_prompt}""" + try: + response = _generate_with_retry( + model, full_content, {"temperature": 0.3, "max_output_tokens": 4096} + ) + except Exception as api_err: + if _is_rate_limit_error(api_err): + raise RuntimeError("Rate limit exceeded. Please try again in a few minutes.") from api_err + raise + if not response or not response.text: + return "No analysis generated." + return response.text.strip() + + +def _run_mda_analysis_background(ticker: str, api_key: str, sec_email: str) -> None: + """Run download + Gemini in background (latest 10-K only for speed). Store result or error in st.session_state.""" + try: + from data.sec_downloader import download_and_extract_item7_and_1a + from data.fundamentals import get_sector_industry + _, item1a, item7_latest = download_and_extract_item7_and_1a(ticker, sec_email) + si = get_sector_industry(ticker) + analysis = get_mda_comparative_insights( + api_key, item1a or "", item7_latest or "", None, ticker, + sector=si.get("sector"), industry=si.get("industry"), + ) + st.session_state["mda_analysis_result"] = analysis + st.session_state["mda_analysis_excerpt"] = ((item1a or "") + "\n\n---\n\n" + (item7_latest or ""))[:12000] + st.session_state["mda_analysis_error"] = None + except Exception as e: + st.session_state["mda_analysis_error"] = str(e) + st.session_state["mda_analysis_result"] = None + st.session_state["mda_analysis_excerpt"] = None + finally: + st.session_state["mda_analysis_running"] = False + st.session_state["mda_analysis_done"] = True + st.session_state["mda_analysis_ticker"] = ticker + + +def get_industry_outlook(api_key: str, industry_name: str, tickers: list) -> str: + """Gemini: Wall Street macro analyst-style Industry Outlook for the selected sector (12\u201318 months).""" + model = get_gemini_model(api_key) + ticker_list_str = ", ".join(str(t).upper() for t in tickers if t) + user_prompt = f"""Act as an elite Wall Street macro analyst. Provide a concise **Industry Outlook** report for the **{industry_name}** sector, which includes leading companies like {ticker_list_str}. + +Focus on: +1. **Macro trends** affecting this industry over the next 12\u201318 months. +2. **Major growth drivers** (e.g., AI, interest rates, consumer spending, regulation). +3. **Key headwinds or regulatory risks** that could impact valuations or growth. + +Use clear headings. Be specific but concise. Keep the response under 600 words.""" + full_content = user_prompt + try: + response = _generate_with_retry( + model, full_content, {"temperature": 0.4, "max_output_tokens": 2048} + ) + except Exception as api_err: + if _is_rate_limit_error(api_err): + raise RuntimeError("Rate limit exceeded. Please try again in a few minutes.") from api_err + raise + if not response or not response.text: + return "No industry outlook generated." + return response.text.strip() diff --git a/ai/gemini_sec.py b/ai/gemini_sec.py new file mode 100644 index 0000000..e4e7bbb --- /dev/null +++ b/ai/gemini_sec.py @@ -0,0 +1,165 @@ +import json +import re +import streamlit as st +from utils.formatting import _safe_float +from data.sec_parser import smart_chunk, clean_text_for_llm +from ai.gemini_core import get_gemini_model, _generate_with_retry, _generate_stream, _gemini_forensic_audit +from config.constants import REQUIRED_FINANCIAL_KEYS + + +@st.cache_data(ttl=3600) +def get_sec_financials_llm(api_key: str, item8_text: str, ticker: str) -> dict: + """Extract Current Year and Previous Year financial figures from 10-K Item 8 via Gemini. Returns dict with current_yr and previous_yr (each with 10 numeric fields). Cached by (api_key, item8_text, ticker).""" + if not (api_key or "").strip() or not (item8_text or "").strip(): + return {} + payload = smart_chunk((item8_text or "").strip(), max_chars=35000) + model = get_gemini_model(api_key) + prompt = f"""You are a financial analyst. Below is Item 8 (Financial Statements and Supplementary Data) from the latest 10-K for {ticker}. + +Extract the following figures for the **Current Year** (most recent fiscal year) and **Previous Year** (prior fiscal year). Use the exact numbers from the financial statements. All monetary values in millions (e.g. 50000 for $50 billion). Shares in millions. + +Return ONLY a valid JSON object, no other text. Use this exact structure: +{{ + "current_yr": {{ + "Revenue": , + "CostOfRevenue": , + "OperatingExpenses": , + "NetIncome": , + "TotalAssets": , + "CurrentAssets": , + "CurrentLiabilities": , + "LongTermDebt": , + "OperatingCashFlow": , + "SharesOutstanding": + }}, + "previous_yr": {{ + "Revenue": , + "CostOfRevenue": , + "OperatingExpenses": , + "NetIncome": , + "TotalAssets": , + "CurrentAssets": , + "CurrentLiabilities": , + "LongTermDebt": , + "OperatingCashFlow": , + "SharesOutstanding": + }} +}} + +If a value is not found in the document, use 0 or a reasonable estimate and still include the key. Output nothing except this JSON.""" + + full = f"""--- Item 8 (Financial Statements) ---\n\n{payload}\n\n---\n\n{prompt}""" + try: + r = _generate_with_retry(model, full, {"temperature": 0.0, "max_output_tokens": 2048}) + raw = (r.text or "").strip() + if not raw: + return {} + raw = re.sub(r"^```\s*json\s*", "", raw) + raw = re.sub(r"^```\s*", "", raw) + raw = re.sub(r"\s*```\s*$", "", raw) + raw = raw.strip() + out = json.loads(raw) + cur = out.get("current_yr") or {} + prev = out.get("previous_yr") or {} + for key in REQUIRED_FINANCIAL_KEYS: + cur[key] = _safe_float(cur.get(key)) or 0 + prev[key] = _safe_float(prev.get(key)) or 0 + return {"current_yr": cur, "previous_yr": prev} + except (json.JSONDecodeError, Exception): + return {} + + +def get_gemini_item7_strategy(api_key: str, item7_text: str, ticker: str, sector: str, industry: str) -> str: + """Item 7 only: business performance, strategic shifts, capital allocation.""" + if not (item7_text or "").strip(): + return "No Item 7 (MD&A) text available." + model = get_gemini_model(api_key) + text = smart_chunk(clean_text_for_llm(item7_text), max_chars=10000) + sector_note = f" Sector: {sector}; Industry: {industry}." if sector and sector != "N/A" else "" + prompt = f"""You are a senior equity analyst. Use British English. The text below is **Item 7 (Management's Discussion and Analysis)** from the latest 10-K for {ticker}.{sector_note} + +Provide a concise **Management Strategy** report with these sections: + +1. **Business performance**: Key revenue, margin, or segment highlights management emphasises. +2. **Strategic shifts**: Changes in priorities, growth drivers, or capital allocation (e.g. capex, M&A, buybacks). +3. **Capital allocation**: How management describes use of cash (dividends, debt paydown, R&D, acquisitions). + +Use clear headings. Do not invent figures. Keep under 600 words. Focus only on narrative insights; ignore missing quantitative data. +Even if the source text is in another language (e.g. Korean or Japanese), analyse it and output your final report strictly in British English.""" + full = f"""--- Item 7 (MD&A) ---\n\n{text}\n\n---\n\n{prompt}""" + try: + r = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 2048}) + return (r.text or "").strip() + except Exception: + return "" + + +def get_gemini_item7_strategy_stream(api_key: str, item7_text: str, ticker: str, sector: str, industry: str): + """Generator that yields MD&A strategy report chunks for real-time streaming (e.g. st.write_stream).""" + if not (item7_text or "").strip(): + yield "No Item 7 (MD&A) text available." + return + model = get_gemini_model(api_key) + text = smart_chunk(clean_text_for_llm(item7_text), max_chars=10000) + sector_note = f" Sector: {sector}; Industry: {industry}." if sector and sector != "N/A" else "" + prompt = f"""You are a senior equity analyst. Use British English. The text below is **Item 7 (Management's Discussion and Analysis)** from the latest 10-K for {ticker}.{sector_note} + +Provide a concise **Management Strategy** report with these sections: + +1. **Business performance**: Key revenue, margin, or segment highlights management emphasises. +2. **Strategic shifts**: Changes in priorities, growth drivers, or capital allocation (e.g. capex, M&A, buybacks). +3. **Capital allocation**: How management describes use of cash (dividends, debt paydown, R&D, acquisitions). + +Use clear headings. Do not invent figures. Keep under 600 words. Focus only on narrative insights; ignore missing quantitative data. +Even if the source text is in another language (e.g. Korean or Japanese), analyse it and output your final report strictly in British English.""" + full = f"""--- Item 7 (MD&A) ---\n\n{text}\n\n---\n\n{prompt}""" + config = {"temperature": 0.3, "max_output_tokens": 2048} + yield from _generate_stream(model, full, config) + + +def get_gemini_item1a_risks(api_key: str, item1a_text: str, item3: str, item9a: str, ticker: str) -> str: + """Item 1A only: legal, operational, market-related threats. Includes Forensic Audit (Item 3 & 9A) as safety check.""" + if not (item1a_text or "").strip(): + return "No Item 1A (Risk Factors) text available." + model = get_gemini_model(api_key) + text = smart_chunk(clean_text_for_llm(item1a_text), max_chars=10000) + prompt = f"""You are a senior equity analyst. Use British English. The text below is **Item 1A (Risk Factors)** from the latest 10-K for {ticker}. + +Provide a concise **Risk Factors** report with these sections: + +1. **Legal & regulatory risks**: Litigation, regulatory changes, compliance. +2. **Operational risks**: Supply chain, key person, technology, execution. +3. **Market & competitive risks**: Demand, competition, macro, currency. + +Use clear headings. Do not invent figures. Keep under 500 words. Focus only on narrative insights; ignore missing quantitative data. +Even if the source text is in another language (e.g. Korean or Japanese), analyse it and output your final report strictly in British English.""" + full = f"""--- Item 1A (Risk Factors) ---\n\n{text}\n\n---\n\n{prompt}""" + try: + report = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 2048}) + risks = (report.text or "").strip() + except Exception: + risks = "" + forensic = _gemini_forensic_audit(api_key, item3 or "", item9a or "", ticker) + return (risks or "") + "\n\n---\n\n**Forensic Audit (Item 3 & 9A)**\n\n" + (forensic or "") + + +def get_gemini_item1a_risks_stream(api_key: str, item1a_text: str, ticker: str): + """Generator that yields Risk Factors report chunks for real-time streaming. Caller appends Forensic (Item 3 & 9A) after stream.""" + if not (item1a_text or "").strip(): + yield "No Item 1A (Risk Factors) text available." + return + model = get_gemini_model(api_key) + text = smart_chunk(clean_text_for_llm(item1a_text), max_chars=10000) + prompt = f"""You are a senior equity analyst. Use British English. The text below is **Item 1A (Risk Factors)** from the latest 10-K for {ticker}. + +Provide a concise **Risk Factors** report with these sections: + +1. **Legal & regulatory risks**: Litigation, regulatory changes, compliance. +2. **Operational risks**: Supply chain, key person, technology, execution. +3. **Market & competitive risks**: Demand, competition, macro, currency. + +Use clear headings. Do not invent figures. Keep under 500 words. Focus only on narrative insights; ignore missing quantitative data. +Even if the source text is in another language (e.g. Korean or Japanese), analyse it and output your final report strictly in British English.""" + full = f"""--- Item 1A (Risk Factors) ---\n\n{text}\n\n---\n\n{prompt}""" + config = {"temperature": 0.3, "max_output_tokens": 2048} + yield from _generate_stream(model, full, config) diff --git a/app.py b/app.py index d5b135b..93035f4 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,5 @@ """ +ATLAS Terminal — Thin Orchestrator All-in-One Financial Analysis Dashboard — Hybrid Architecture - Tab 1: 10-K & MD&A Insights (Item 7 + Item 1A → Gemini, qualitative only). - Tab 2: 3-Scenario DCF Valuation (yfinance + sliders, no LLM). @@ -8,62 +9,23 @@ All-in-One Financial Analysis Dashboard — Hybrid Architecture import os os.environ["OBJC_DISABLE_INITIALIZE_FORK_SAFETY"] = "YES" - -import json -import os -import re -import tempfile -import threading -import time -from pathlib import Path -from typing import Optional, Any - - -# Local prefs file for "Remember me" (API key & email). Path is in .gitignore. -_PREFS_PATH = Path(__file__).resolve().parent / ".app_prefs.json" -# Persistent 10-K cache: data/ticker_latest.json (Item 1A, 3, 7, 9A cleaned text). -_DATA_DIR = Path(__file__).resolve().parent / "data" - - -def _load_prefs() -> dict: - """Load saved API keys and email from local file. Keys: google_api_key, sec_email.""" - try: - if _PREFS_PATH.exists(): - with open(_PREFS_PATH, "r", encoding="utf-8") as f: - return json.load(f) - except Exception: - pass - return {} - - -def _save_prefs(google_api_key: str, sec_email: str) -> None: - """Save API keys and email to local file (only if user opted in).""" - try: - data = { - "google_api_key": (google_api_key or "").strip(), - "sec_email": (sec_email or "").strip(), - } - with open(_PREFS_PATH, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) - except Exception: - pass - import streamlit as st -import pandas as pd -from bs4 import BeautifulSoup -try: - import plotly.express as px - import plotly.graph_objects as go -except ImportError: - px = None - go = None - -try: - from dotenv import load_dotenv - load_dotenv() -except ImportError: - pass +from config.constants import MARKET_OPTIONS +from config.theme import SOFT_NAVY_CSS, HEADER_HTML +from utils.ticker import get_global_ticker +from data.market import _get_ticker_bar_data +from data.fundamentals import get_sector_industry +from views.sidebar import render_sidebar +from views.tab1_quant import render_tab1_quantitative +from views.tab1_ai import render_tab1_ai_analysis +from views.tab1_filings import render_tab1_filings +from views.tab2_dcf import render_tab2 +from views.tab3_comps import render_tab3 +from views.tab4_news import render_tab4 +from views.tab5_markets import render_tab5 +from views.tab6_crypto import render_tab6 +from views.tab7_technical import render_tab7 try: import yfinance as yf @@ -71,2243 +33,48 @@ except ImportError: yf = None try: - from yahooquery import Ticker as YQTicker - from yahooquery import search as yq_search + from dotenv import load_dotenv + load_dotenv() except ImportError: - YQTicker = None - yq_search = None + pass -# Company name → ticker for search/autocomplete (expand as needed) -COMPANY_LIST = [ - ("NVIDIA Corporation", "NVDA"), ("Apple Inc.", "AAPL"), ("Microsoft Corporation", "MSFT"), - ("Amazon.com Inc.", "AMZN"), ("Alphabet Inc.", "GOOGL"), ("Meta Platforms Inc.", "META"), - ("AMD", "AMD"), ("Intel Corporation", "INTC"), ("Qualcomm Inc.", "QCOM"), ("Tesla Inc.", "TSLA"), - ("Berkshire Hathaway", "BRK.B"), ("JPMorgan Chase", "JPM"), ("Visa Inc.", "V"), ("UnitedHealth", "UNH"), - ("Procter & Gamble", "PG"), ("Exxon Mobil", "XOM"), ("Johnson & Johnson", "JNJ"), ("Mastercard", "MA"), - ("Chevron", "CVX"), ("Home Depot", "HD"), ("Merck", "MRK"), ("AbbVie", "ABBV"), ("Costco", "COST"), - ("PepsiCo", "PEP"), ("Coca-Cola", "KO"), ("Pfizer", "PFE"), ("Walmart", "WMT"), ("Netflix", "NFLX"), - ("Adobe", "ADBE"), ("Salesforce", "CRM"), ("Comcast", "CMCSA"), ("Cisco", "CSCO"), ("Oracle", "ORCL"), - ("American Express", "AXP"), ("Bank of America", "BAC"), ("Wells Fargo", "WFC"), ("Verizon", "VZ"), - ("AT&T", "T"), ("Walt Disney", "DIS"), ("Nike", "NKE"), ("McDonald's", "MCD"), ("Starbucks", "SBUX"), - ("Goldman Sachs", "GS"), ("Morgan Stanley", "MS"), ("Target", "TGT"), ("Boeing", "BA"), ("IBM", "IBM"), -] -COMPANY_OPTIONS = [f"{t} - {n}" for n, t in COMPANY_LIST] -COMPANY_TICKER_MAP = {t: n for n, t in COMPANY_LIST} +# ---------- Page config & theme ---------- +st.set_page_config(page_title="ATLAS Terminal", layout="wide", initial_sidebar_state="expanded") +st.markdown(SOFT_NAVY_CSS, unsafe_allow_html=True) +st.markdown(HEADER_HTML, unsafe_allow_html=True) -MARKET_OPTIONS = [ - "US (S&P/Dow/Nasdaq)", - "South Korea (KOSPI/KOSDAQ)", - "Japan (Nikkei)", - "UK (LSE)", -] - - -def get_global_ticker(ticker: str, market: str) -> str: - """Format ticker for Yahoo Finance by market. US: as-is. South Korea: .KS or .KQ. Japan: .T. UK: .L. If ticker already has suffix, return as-is.""" - if not (ticker or "").strip(): - return (ticker or "").strip() - t = (ticker or "").strip() - if t.upper().endswith((".KS", ".KQ", ".T", ".L")): - return t - m = (market or "").strip() - if "US" in m or not m: - return t - if "Korea" in m or "KOSPI" in m or "KOSDAQ" in m: - return t + ".KS" - if "Japan" in m or "Nikkei" in m: - return t + ".T" - if "UK" in m or "LSE" in m: - return t + ".L" - return t - - -def infer_market_from_ticker(ticker: str) -> str: - """Infer market label from ticker suffix (for Deep-Dive routing when no Market selector).""" - if not (ticker or "").strip(): - return MARKET_OPTIONS[0] - t = (ticker or "").strip().upper() - if t.endswith(".KS") or t.endswith(".KQ"): - return "South Korea (KOSPI/KOSDAQ)" - if t.endswith(".T"): - return "Japan (Nikkei)" - if t.endswith(".L"): - return "UK (LSE)" - return "US (S&P/Dow/Nasdaq)" - - -# Top-down sector analysis: industry → top 5 S&P 500 / NASDAQ tickers -SECTORS = { - "Semiconductors & Hardware": ["NVDA", "AMD", "INTC", "TSM", "AVGO"], - "Software & Cloud": ["MSFT", "ADBE", "CRM", "PANW", "CRWD"], - "Consumer Retail": ["AMZN", "SBUX", "MCD", "WMT", "HD"], - "Financial Services": ["JPM", "BAC", "GS", "MS", "V"], - "Healthcare": ["LLY", "UNH", "JNJ", "ABBV", "MRK"], -} - - -def get_edgar_downloader(): - from sec_edgar_downloader import Downloader - return Downloader - - -def _slice_html_items_1a_to_9a(raw_html: str) -> str: - """Fast string slice: keep only Item 1A through end of Item 9A to avoid parsing 50MB+ full file. Uses .find()/regex on raw string only.""" - if not raw_html or len(raw_html) < 5000: - return raw_html - start = -1 - for needle in ("Item 1A", "ITEM 1A", "Item 1a"): - i = raw_html.find(needle) - if i != -1 and (start == -1 or i < start): - start = i - if start == -1: - m = re.search(r"Item\s+1A\s", raw_html, re.IGNORECASE) - start = m.start() if m else 0 - else: - start = max(0, start - 200) - search_region = raw_html[start:] - end_match = re.search(r"Item\s+10\s|Item\s+12\s|Part\s+III\b|PART\s+III\b", search_region, re.IGNORECASE) - end = start + end_match.start() if end_match else len(raw_html) - end = min(end, start + 8_000_000) - return raw_html[start:end] - - -def _extract_text_from_html_string(html_str: str) -> str: - """Parse HTML string with lxml; drop table/img/svg/style/script immediately to reduce memory and speed.""" - if not html_str or not html_str.strip(): - return "" - try: - soup = BeautifulSoup(html_str, "lxml") - except Exception: - soup = BeautifulSoup(html_str, "html.parser") - for tag in soup.find_all(["table", "img", "svg", "style", "script"]): - tag.decompose() - return soup.get_text(separator="\n", strip=True) - - -def extract_text_from_html(html_path: Path) -> str: - try: - with open(html_path, "r", encoding="utf-8", errors="replace") as f: - raw = f.read() - except Exception: - with open(html_path, "r", encoding="latin-1", errors="replace") as f: - raw = f.read() - chunk = _slice_html_items_1a_to_9a(raw) - return _extract_text_from_html_string(chunk) - - -def extract_text_from_file(file_path: Path) -> str: - suf = file_path.suffix.lower() - if suf in (".htm", ".html"): - return extract_text_from_html(file_path) - if suf == ".txt": - with open(file_path, "r", encoding="utf-8", errors="replace") as f: - text = f.read() - text = re.sub(r"<[^>]+>", " ", text) - text = re.sub(r"\s+", " ", text) - return text - return "" - - -# Section patterns for 10-K items -ITEM1A_PATTERNS = [ - r"Item\s+1A\s*[.:]\s*Risk\s+Factors", - r"ITEM\s+1A\s*[.:]\s*Risk\s+Factors", -] -ITEM7_PATTERNS = [ - r"Item\s+7\s*[.:]\s*Management['\u2019]s\s+Discussion\s+and\s+Analysis", - r"ITEM\s+7\s*[.:]\s*Management['\u2019]s\s+Discussion", - r"Item\s+7\s*[.:]\s*[\w\s]+MD&A", -] -ITEM8_PATTERNS = [ - r"Item\s+8\s*[.:]\s*Financial\s+Statements", - r"ITEM\s+8\s*[.:]\s*Financial\s+Statements", -] -ITEM3_PATTERNS = [ - r"Item\s+3\s*[.:]\s*Legal\s+Proceedings", - r"ITEM\s+3\s*[.:]\s*Legal\s+Proceedings", -] -ITEM9A_PATTERNS = [ - r"Item\s+9A\s*[.:]\s*Controls\s+and\s+Procedures", - r"Item\s+9A\s*[.:]\s*Internal\s+Control", - r"ITEM\s+9A\s*[.:]\s*Controls", -] - - -def _find_section_start(text: str, patterns: list, item_num: int) -> int: - for pat in patterns: - m = re.search(pat, text, re.IGNORECASE) - if m: - return m.start() - m = re.search(r"\bItem\s+" + str(item_num) + r"\b", text, re.IGNORECASE) - return m.start() if m else -1 - - -def find_item_section_generic(text: str, patterns: list, item_num: int, title_keywords: list, max_chars: int = 120000) -> str: - start = _find_section_start(text, patterns, item_num) - if start == -1: - pattern = re.compile( - r"\bItem\s+" + str(item_num) + r"\b[.\s]*[^\n]*(" + "|".join(re.escape(k) for k in title_keywords) + r")?", - re.IGNORECASE, +# ---------- Ticker bar — major indices & crypto ---------- +ticker_data = _get_ticker_bar_data() if yf else [] +if ticker_data: + _cells = "" + for item in ticker_data: + _c = "#34D399" if item["change"] >= 0 else "#F87171" + _a = "\u25b2" if item["change"] >= 0 else "\u25bc" + _cells += ( + f'
' + f'
{item["label"]}
' + f'
{item["price"]:,.2f}
' + f'
{_a} {item["change"]:+.2f}%
' + f'
' ) - match = pattern.search(text) - if not match: - return "" - start = match.start() - next_item = re.search(r"\n\s*Item\s+\d+[A-Z]?\s+", text[start + 100:], re.IGNORECASE) - if next_item: - end = start + 100 + next_item.start() - else: - end = min(start + max_chars, len(text)) - return text[start:end].strip() - - -def clean_text_for_llm(html_content: str) -> str: - """Aggressive cleaning for LLM: strip tables/code, collapse whitespace, drop non-ASCII. Uses lxml for speed; drops table/img/svg/style/script.""" - if not html_content or not html_content.strip(): - return "" - try: - soup = BeautifulSoup(html_content, "lxml") - for tag in soup.find_all(["table", "img", "style", "script", "svg", "math"]): - tag.decompose() - text = soup.get_text(separator=" ") - except Exception: - text = re.sub(r"<[^>]+>", " ", html_content) - text = re.sub(r"\s+", " ", text) - text = " ".join(text.split()) - text = re.sub(r"[^\x20-\x7E\n]", " ", text) - text = re.sub(r"\s+", " ", text).strip() - lines = [] - for line in text.split("\n"): - line = line.strip() - if not line: - continue - if re.fullmatch(r"\d+", line) or re.fullmatch(r"[\.\-\s\-]+", line): - continue - if re.match(r"^(page\s+\d+|\d+)\s*$", line, re.IGNORECASE) and len(line) < 20: - continue - lines.append(line) - result = " ".join(lines) - result = re.sub(r"\s+", " ", result).strip() - return result - - -def smart_chunk(section: str, max_chars: int = 10000, head_ratio: float = 0.5) -> str: - """Limit payload for Gemini; 10k chars ≈ 2.5k tokens for fast response.""" - if not section or len(section) <= max_chars: - return section - head_size = int(max_chars * head_ratio) - tail_size = max_chars - head_size - 100 - return section[:head_size] + " [ ... middle omitted ... ] " + section[-tail_size:] - - -def find_downloaded_10k_path(download_root: Path, ticker: str) -> Optional[Path]: - ticker_upper = ticker.upper() - for base in (download_root / "sec-edgar-filings", download_root): - path_10k = base / ticker_upper / "10-K" - if path_10k.exists(): - subdirs = sorted([d for d in path_10k.iterdir() if d.is_dir()], key=lambda x: x.name, reverse=True) - if subdirs: - return subdirs[0] - for base in (download_root / "sec-edgar-filings", download_root): - if not base.exists(): - continue - for company_dir in base.iterdir(): - if not company_dir.is_dir(): - continue - path_10k = company_dir / "10-K" - if path_10k.exists(): - subdirs = sorted([d for d in path_10k.iterdir() if d.is_dir()], key=lambda x: x.name, reverse=True) - if subdirs: - return subdirs[0] - return None - - -def find_all_10k_filing_dirs(download_root: Path, ticker: str) -> list: - """Return list of 10-K filing dirs sorted newest first (for multi-year comparison).""" - ticker_upper = ticker.upper() - for base in (download_root / "sec-edgar-filings", download_root): - path_10k = base / ticker_upper / "10-K" - if path_10k.exists(): - subdirs = sorted([d for d in path_10k.iterdir() if d.is_dir()], key=lambda x: x.name, reverse=True) - return subdirs - return [] - - -def get_main_10k_text(filing_dir: Path) -> str: - all_text = [] - for ext in ("*.htm", "*.html", "*.txt"): - for path in filing_dir.rglob(ext): - try: - t = extract_text_from_file(path) - if len(t) > 1000: - all_text.append((path, t)) - except Exception: - continue - if not all_text: - return "" - _, main_text = max(all_text, key=lambda x: len(x[1])) - return main_text - - -def _get_10k_cache_path(ticker: str) -> Path: - """Path for cached 10-K sections: data/TICKER_latest.json.""" - _DATA_DIR.mkdir(parents=True, exist_ok=True) - return _DATA_DIR / f"{ticker.upper()}_latest.json" - - -def _load_10k_from_cache(ticker: str) -> Optional[dict]: - """Load Item 1A, 3, 7, 8, 9A (plain text) from data/ticker_latest.json. Returns None if missing. When present, no re-download or re-parse.""" - path = _get_10k_cache_path(ticker) - if not path.exists(): - return None - try: - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - except Exception: - return None - - -def _save_10k_to_cache(ticker: str, data: dict) -> None: - """Save cleaned 10-K sections to data/ticker_latest.json.""" - path = _get_10k_cache_path(ticker) - _DATA_DIR.mkdir(parents=True, exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=0) - - -def _extract_item_from_full(text: str, patterns: list, item_num: int, keywords: list, max_chars: int = 60000) -> str: - """Extract one item section from full 10-K text.""" - start = _find_section_start(text, patterns, item_num) - if start < 0: - pattern = re.compile(r"\bItem\s+" + str(item_num) + r"[A-Z]?\b[.\s]*[^\n]*", re.IGNORECASE) - match = pattern.search(text) - start = match.start() if match else -1 - if start < 0: - return "" - next_item = re.search(r"\n\s*Item\s+\d+[A-Z]?\s+", text[start + 100:], re.IGNORECASE) - end = start + 100 + next_item.start() if next_item else min(start + max_chars, len(text)) - return text[start:end].strip() - - -def download_and_extract_all_items(ticker: str, email: str) -> dict: - """Download latest 10-K, extract Item 1A, 3, 7, 9A; clean and return (and optionally cache).""" - Downloader = get_edgar_downloader() - with tempfile.TemporaryDirectory() as tmpdir: - download_root = Path(tmpdir) - dl = Downloader("FQDC-10K-Analyzer", email, str(download_root)) - dl.get("10-K", ticker.upper(), limit=1, download_details=True) - filing_dir = find_downloaded_10k_path(download_root, ticker) - if not filing_dir: - raise FileNotFoundError(f"Could not find 10-K for ticker '{ticker}'.") - full_text = get_main_10k_text(filing_dir) - if not full_text: - raise ValueError("Could not extract text from the 10-K.") - item1a = find_item_section_generic(full_text, ITEM1A_PATTERNS, 1, ["Risk", "Factors"], max_chars=80000) - item3 = _extract_item_from_full(full_text, ITEM3_PATTERNS, 3, ["Legal", "Proceedings"], max_chars=40000) - item9a = _extract_item_from_full(full_text, ITEM9A_PATTERNS, 9, ["Controls", "Procedures", "Internal"], max_chars=40000) - start7 = _find_section_start(full_text, ITEM7_PATTERNS, 7) - text_after_7 = full_text[start7:] if start7 >= 0 else full_text - item7 = find_item_section_generic(text_after_7, ITEM7_PATTERNS, 7, ["Management's Discussion", "MD&A", "Analysis"], max_chars=100000) - if not item7 and text_after_7: - item7 = text_after_7[:120000] - item8 = _extract_item_from_full(full_text, ITEM8_PATTERNS, 8, ["Financial Statements", "Supplementary Data"], max_chars=200000) - data = { - "item1a": clean_text_for_llm(item1a or ""), - "item3": clean_text_for_llm(item3 or ""), - "item9a": clean_text_for_llm(item9a or ""), - "item7": clean_text_for_llm(item7 or ""), - "item8": clean_text_for_llm(item8 or ""), - } - _save_10k_to_cache(ticker, data) - return data - - -def get_10k_sections(ticker: str, email: str) -> tuple[dict, str]: - """Return (sections dict, status). status = 'cache' if loaded from file else 'downloaded'. Cached ticker skips download and parsing entirely (item1a, item3, item7, item8, item9a).""" - cached = _load_10k_from_cache(ticker) - if cached is not None: - return cached, "cache" - return download_and_extract_all_items(ticker, email), "downloaded" - - -def download_and_extract_item7_and_1a(ticker: str, email: str) -> tuple[str, str, str]: - """Fetch 10-K from SEC EDGAR and return full_text, Item 1A (Risk Factors), Item 7 (MD&A). Uses cache when available.""" - sections, _ = get_10k_sections(ticker, email) - return "", sections.get("item1a", "") or "", sections.get("item7", "") or "" - - -def download_item7_latest_and_3y_ago(ticker: str, email: str) -> tuple[Optional[str], Optional[str], Optional[str], bool]: - """Download up to 5 10-Ks; extract Item 1A (latest only) and Item 7 from latest and from 3 years ago. - Returns (item1a_latest, item7_latest, item7_3y_ago, has_comparison). If < 4 filings, item7_3y_ago is None.""" - Downloader = get_edgar_downloader() - with tempfile.TemporaryDirectory() as tmpdir: - download_root = Path(tmpdir) - dl = Downloader("FQDC-10K-Analyzer", email, str(download_root)) - dl.get("10-K", ticker.upper(), limit=5, download_details=True) - filing_dirs = find_all_10k_filing_dirs(download_root, ticker) - if not filing_dirs: - raise FileNotFoundError(f"Could not find 10-K for ticker '{ticker}'.") - full_latest = get_main_10k_text(filing_dirs[0]) - if not full_latest: - raise ValueError("Could not extract text from the latest 10-K.") - item1a = find_item_section_generic( - full_latest, ITEM1A_PATTERNS, 1, ["Risk", "Factors"], max_chars=80000 - ) - text_after_7 = full_latest[_find_section_start(full_latest, ITEM7_PATTERNS, 7):] if _find_section_start(full_latest, ITEM7_PATTERNS, 7) >= 0 else full_latest - item7_latest = find_item_section_generic( - text_after_7, ITEM7_PATTERNS, 7, ["Management's Discussion", "MD&A", "Analysis"], max_chars=100000 - ) - if not item7_latest and text_after_7: - item7_latest = smart_chunk(text_after_7[:120000], max_chars=20000) - item7_3y_ago = None - has_comparison = False - if len(filing_dirs) >= 4: - full_3y = get_main_10k_text(filing_dirs[3]) - if full_3y: - text_3y = full_3y[_find_section_start(full_3y, ITEM7_PATTERNS, 7):] if _find_section_start(full_3y, ITEM7_PATTERNS, 7) >= 0 else full_3y - item7_3y_ago = find_item_section_generic( - text_3y, ITEM7_PATTERNS, 7, ["Management's Discussion", "MD&A", "Analysis"], max_chars=100000 - ) - if not item7_3y_ago and text_3y: - item7_3y_ago = smart_chunk(text_3y[:120000], max_chars=20000) - has_comparison = bool(item7_3y_ago) - return item1a or "", item7_latest or "", item7_3y_ago, has_comparison - - -# ---------- Gemini (qualitative only) ---------- -GEMINI_MODEL = "gemini-2.0-flash" -RATE_LIMIT_WAIT_SEC = 60 - - -def get_gemini_model(api_key: str): - import google.generativeai as genai - genai.configure(api_key=api_key) - return genai.GenerativeModel(GEMINI_MODEL) - - -def _is_rate_limit_error(e: Exception) -> bool: - err_msg = str(e).lower() - return "429" in err_msg or "resourcelimited" in err_msg or "resource exhausted" in err_msg or getattr(e, "code", None) == 429 - - -def _generate_with_retry(model, content, config, max_retries: int = 3): - last_err = None - for attempt in range(max_retries + 1): - try: - return model.generate_content(content, generation_config=config) - except Exception as e: - last_err = e - if attempt < max_retries and _is_rate_limit_error(e): - time.sleep(RATE_LIMIT_WAIT_SEC) - continue - raise - raise last_err - - -def _generate_stream(model, content, config): - """Yield text chunks from Gemini with stream=True. For use with st.write_stream().""" - try: - response = model.generate_content(content, generation_config=config, stream=True) - for chunk in response: - if hasattr(chunk, "text") and chunk.text: - yield chunk.text - except Exception: - raise - - -def _split_into_chunks(text: str, max_chars: int = 22000, min_chunk: int = 5000) -> list: - """Split text into sequential chunks without cutting mid-sentence when possible.""" - if not text or len(text) <= max_chars: - return [text] if text and text.strip() else [] - chunks = [] - start = 0 - while start < len(text): - end = min(start + max_chars, len(text)) - if end < len(text): - break_at = text.rfind("\n\n", start, end + 1) - if break_at > start + min_chunk: - end = break_at + 2 - chunks.append(text[start:end].strip()) - start = end - return [c for c in chunks if c] - - -def _gemini_summarize_segment(api_key: str, segment_text: str, ticker: str, segment_label: str) -> str: - """Extract strategic shifts and hidden risks from one segment. No trimming.""" - model = get_gemini_model(api_key) - prompt = f"""You are a senior equity analyst. The following is one segment of the 10-K for {ticker} (Item 1A Risk Factors and/or Item 7 MD&A). -Extract and list all significant: (1) strategic shifts or priorities, (2) hidden or material risks, (3) management tone cues. Use concise bullet points. Do not omit important details. Segment: {segment_label}.""" - full = f"""--- 10-K Segment ---\n\n{segment_text[:50000]}\n\n---\n\n{prompt}""" - try: - r = _generate_with_retry(model, full, {"temperature": 0.2, "max_output_tokens": 2048}) - return (r.text or "").strip() - except Exception: - return "" - - -def _gemini_synthesize_report(api_key: str, segment_summaries: list, ticker: str, sector: str, industry: str) -> str: - """Synthesis call: turn segment summaries into Executive Insight Report.""" - model = get_gemini_model(api_key) - combined = "\n\n---\n\n".join(segment_summaries) - kpi_note = f" Sector: {sector}; Industry: {industry}. Include industry-specific KPIs if mentioned." if sector and sector != "N/A" else "" - prompt = f"""You are a senior equity analyst. Use British English. Below are summarized insights from the full 10-K for {ticker} (Item 1A and Item 7). Create the final **Executive Insight Report** with these sections: - -1. **Management's Tone (Sentiment)**: Overall tone and supporting evidence. -2. **Current Strategy & Priorities**: Key strategic focus, capital allocation, growth drivers. -3. **Major Hidden Risks**: The 3–4 most material risks investors might overlook. -4. **Forensic / Quality of Earnings**: Accounting caveats, one-offs, cash flow vs earnings. If none material, say so briefly.{kpi_note} - -Use clear headings. Do not invent figures. Keep under 900 words.""" - full = f"""--- Segment Summaries ---\n\n{combined}\n\n---\n\n{prompt}""" - try: - r = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 4096}) - return (r.text or "").strip() - except Exception: - return "" - - -def _gemini_forensic_audit(api_key: str, item3: str, item9a: str, ticker: str) -> str: - """Dedicated high-priority check: Material Weaknesses, lawsuits, off-balance-sheet from Item 3 and 9A.""" - model = get_gemini_model(api_key) - combined = (item3 or "") + "\n\n---\n\n" + (item9a or "") - if not combined.strip(): - return "✅ No Item 3 / 9A text provided; skip forensic." - prompt = f"""From the following 10-K excerpts for {ticker} (Item 3 Legal Proceedings and Item 9A Controls/Internal Control), list any: -- Material weaknesses in internal control -- Significant legal proceedings or litigation -- Off-balance-sheet or governance red flags -If none of the above, output exactly: "✅ No material red flags or special issues detected in Item 3 and 9A." -Be concise (under 150 words).""" - full = f"""--- Item 3 & 9A ---\n\n{combined[:30000]}\n\n---\n\n{prompt}""" - try: - r = _generate_with_retry(model, full, {"temperature": 0.1, "max_output_tokens": 512}) - return (r.text or "").strip() - except Exception: - return "" - - -_REQUIRED_FINANCIAL_KEYS = [ - "Revenue", "CostOfRevenue", "OperatingExpenses", "NetIncome", - "TotalAssets", "CurrentAssets", "CurrentLiabilities", "LongTermDebt", - "OperatingCashFlow", "SharesOutstanding", -] - - -@st.cache_data(ttl=3600) -def get_sec_financials_llm(api_key: str, item8_text: str, ticker: str) -> dict: - """Extract Current Year and Previous Year financial figures from 10-K Item 8 via Gemini. Returns dict with current_yr and previous_yr (each with 10 numeric fields). Cached by (api_key, item8_text, ticker).""" - if not (api_key or "").strip() or not (item8_text or "").strip(): - return {} - payload = smart_chunk((item8_text or "").strip(), max_chars=35000) - model = get_gemini_model(api_key) - prompt = f"""You are a financial analyst. Below is Item 8 (Financial Statements and Supplementary Data) from the latest 10-K for {ticker}. - -Extract the following figures for the **Current Year** (most recent fiscal year) and **Previous Year** (prior fiscal year). Use the exact numbers from the financial statements. All monetary values in millions (e.g. 50000 for $50 billion). Shares in millions. - -Return ONLY a valid JSON object, no other text. Use this exact structure: -{{ - "current_yr": {{ - "Revenue": , - "CostOfRevenue": , - "OperatingExpenses": , - "NetIncome": , - "TotalAssets": , - "CurrentAssets": , - "CurrentLiabilities": , - "LongTermDebt": , - "OperatingCashFlow": , - "SharesOutstanding": - }}, - "previous_yr": {{ - "Revenue": , - "CostOfRevenue": , - "OperatingExpenses": , - "NetIncome": , - "TotalAssets": , - "CurrentAssets": , - "CurrentLiabilities": , - "LongTermDebt": , - "OperatingCashFlow": , - "SharesOutstanding": - }} -}} - -If a value is not found in the document, use 0 or a reasonable estimate and still include the key. Output nothing except this JSON.""" - - full = f"""--- Item 8 (Financial Statements) ---\n\n{payload}\n\n---\n\n{prompt}""" - try: - r = _generate_with_retry(model, full, {"temperature": 0.0, "max_output_tokens": 2048}) - raw = (r.text or "").strip() - if not raw: - return {} - raw = re.sub(r"^```\s*json\s*", "", raw) - raw = re.sub(r"^```\s*", "", raw) - raw = re.sub(r"\s*```\s*$", "", raw) - raw = raw.strip() - out = json.loads(raw) - cur = out.get("current_yr") or {} - prev = out.get("previous_yr") or {} - for key in _REQUIRED_FINANCIAL_KEYS: - cur[key] = _safe_float(cur.get(key)) or 0 - prev[key] = _safe_float(prev.get(key)) or 0 - return {"current_yr": cur, "previous_yr": prev} - except (json.JSONDecodeError, Exception): - return {} - - -def get_gemini_item7_strategy(api_key: str, item7_text: str, ticker: str, sector: str, industry: str) -> str: - """Item 7 only: business performance, strategic shifts, capital allocation.""" - if not (item7_text or "").strip(): - return "No Item 7 (MD&A) text available." - model = get_gemini_model(api_key) - text = smart_chunk(clean_text_for_llm(item7_text), max_chars=10000) - sector_note = f" Sector: {sector}; Industry: {industry}." if sector and sector != "N/A" else "" - prompt = f"""You are a senior equity analyst. Use British English. The text below is **Item 7 (Management's Discussion and Analysis)** from the latest 10-K for {ticker}.{sector_note} - -Provide a concise **Management Strategy** report with these sections: - -1. **Business performance**: Key revenue, margin, or segment highlights management emphasises. -2. **Strategic shifts**: Changes in priorities, growth drivers, or capital allocation (e.g. capex, M&A, buybacks). -3. **Capital allocation**: How management describes use of cash (dividends, debt paydown, R&D, acquisitions). - -Use clear headings. Do not invent figures. Keep under 600 words. Focus only on narrative insights; ignore missing quantitative data. -Even if the source text is in another language (e.g. Korean or Japanese), analyse it and output your final report strictly in British English.""" - full = f"""--- Item 7 (MD&A) ---\n\n{text}\n\n---\n\n{prompt}""" - try: - r = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 2048}) - return (r.text or "").strip() - except Exception: - return "" - - -def get_gemini_item7_strategy_stream(api_key: str, item7_text: str, ticker: str, sector: str, industry: str): - """Generator that yields MD&A strategy report chunks for real-time streaming (e.g. st.write_stream).""" - if not (item7_text or "").strip(): - yield "No Item 7 (MD&A) text available." - return - model = get_gemini_model(api_key) - text = smart_chunk(clean_text_for_llm(item7_text), max_chars=10000) - sector_note = f" Sector: {sector}; Industry: {industry}." if sector and sector != "N/A" else "" - prompt = f"""You are a senior equity analyst. Use British English. The text below is **Item 7 (Management's Discussion and Analysis)** from the latest 10-K for {ticker}.{sector_note} - -Provide a concise **Management Strategy** report with these sections: - -1. **Business performance**: Key revenue, margin, or segment highlights management emphasises. -2. **Strategic shifts**: Changes in priorities, growth drivers, or capital allocation (e.g. capex, M&A, buybacks). -3. **Capital allocation**: How management describes use of cash (dividends, debt paydown, R&D, acquisitions). - -Use clear headings. Do not invent figures. Keep under 600 words. Focus only on narrative insights; ignore missing quantitative data. -Even if the source text is in another language (e.g. Korean or Japanese), analyse it and output your final report strictly in British English.""" - full = f"""--- Item 7 (MD&A) ---\n\n{text}\n\n---\n\n{prompt}""" - config = {"temperature": 0.3, "max_output_tokens": 2048} - yield from _generate_stream(model, full, config) - - -def get_gemini_item1a_risks(api_key: str, item1a_text: str, item3: str, item9a: str, ticker: str) -> str: - """Item 1A only: legal, operational, market-related threats. Includes Forensic Audit (Item 3 & 9A) as safety check.""" - if not (item1a_text or "").strip(): - return "No Item 1A (Risk Factors) text available." - model = get_gemini_model(api_key) - text = smart_chunk(clean_text_for_llm(item1a_text), max_chars=10000) - prompt = f"""You are a senior equity analyst. Use British English. The text below is **Item 1A (Risk Factors)** from the latest 10-K for {ticker}. - -Provide a concise **Risk Factors** report with these sections: - -1. **Legal & regulatory risks**: Litigation, regulatory changes, compliance. -2. **Operational risks**: Supply chain, key person, technology, execution. -3. **Market & competitive risks**: Demand, competition, macro, currency. - -Use clear headings. Do not invent figures. Keep under 500 words. Focus only on narrative insights; ignore missing quantitative data. -Even if the source text is in another language (e.g. Korean or Japanese), analyse it and output your final report strictly in British English.""" - full = f"""--- Item 1A (Risk Factors) ---\n\n{text}\n\n---\n\n{prompt}""" - try: - report = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 2048}) - risks = (report.text or "").strip() - except Exception: - risks = "" - forensic = _gemini_forensic_audit(api_key, item3 or "", item9a or "", ticker) - return (risks or "") + "\n\n---\n\n**Forensic Audit (Item 3 & 9A)**\n\n" + (forensic or "") - - -def get_gemini_item1a_risks_stream(api_key: str, item1a_text: str, ticker: str): - """Generator that yields Risk Factors report chunks for real-time streaming. Caller appends Forensic (Item 3 & 9A) after stream.""" - if not (item1a_text or "").strip(): - yield "No Item 1A (Risk Factors) text available." - return - model = get_gemini_model(api_key) - text = smart_chunk(clean_text_for_llm(item1a_text), max_chars=10000) - prompt = f"""You are a senior equity analyst. Use British English. The text below is **Item 1A (Risk Factors)** from the latest 10-K for {ticker}. - -Provide a concise **Risk Factors** report with these sections: - -1. **Legal & regulatory risks**: Litigation, regulatory changes, compliance. -2. **Operational risks**: Supply chain, key person, technology, execution. -3. **Market & competitive risks**: Demand, competition, macro, currency. - -Use clear headings. Do not invent figures. Keep under 500 words. Focus only on narrative insights; ignore missing quantitative data. -Even if the source text is in another language (e.g. Korean or Japanese), analyse it and output your final report strictly in British English.""" - full = f"""--- Item 1A (Risk Factors) ---\n\n{text}\n\n---\n\n{prompt}""" - config = {"temperature": 0.3, "max_output_tokens": 2048} - yield from _generate_stream(model, full, config) - - -def get_mda_chunked_insights( - api_key: str, sections: dict, ticker: str, sector: str, industry: str, progress_callback=None -) -> str: - """Full-text analysis: chunk 1A+7, summarize each segment, synthesize report; then append forensic (Item 3, 9A). progress_callback(step: str) optional.""" - def _progress(msg): - if progress_callback: - progress_callback(msg) - combined = (sections.get("item1a") or "") + "\n\n---\n\n" + (sections.get("item7") or "") - combined = combined.strip() - if not combined: - return "No 10-K text available to analyse." - chunks = _split_into_chunks(combined, max_chars=22000) - if not chunks: - return "No content extracted." - summaries = [] - n = len(chunks) - for i, ch in enumerate(chunks): - _progress(f"Analyzing Segment {i+1}/{n}...") - summary = _gemini_summarize_segment(api_key, ch, ticker, f"Segment {i+1}/{n}") - if summary: - summaries.append(summary) - if not summaries: - return "Segment analysis produced no summaries." - _progress("Synthesizing final report...") - report = _gemini_synthesize_report(api_key, summaries, ticker, sector or "N/A", industry or "N/A") - _progress("Running forensic audit (Item 3 & 9A)...") - forensic = _gemini_forensic_audit(api_key, sections.get("item3") or "", sections.get("item9a") or "", ticker) - return (report or "") + "\n\n---\n\n**Forensic (Item 3 & 9A)**\n\n" + (forensic or "") - - -def get_mda_insights(api_key: str, item1a_text: str, item7_text: str, ticker: str) -> str: - """Send Item 1A + Item 7 to Gemini. Analyse: 1) Management's Tone (Sentiment), 2) Key Strategic Shifts, 3) Major Hidden Risks.""" - model = get_gemini_model(api_key) - combined = [] - if item1a_text: - combined.append(clean_text_for_llm(item1a_text)) - if item7_text: - combined.append(clean_text_for_llm(item7_text)) - combined_text = "\n\n---\n\n".join(combined) - combined_text = smart_chunk(combined_text, max_chars=22000) - - user_prompt = f"""You are a senior equity analyst. Use British English. - -The text below is from the 10-K for {ticker}: **Item 1A (Risk Factors)** and **Item 7 (Management's Discussion and Analysis)**. HTML has been stripped; analyse only the substance. - -Provide a concise report with three sections: - -1. **Management's Tone (Sentiment)**: Is the overall tone positive, cautious, or negative? Quote 1–2 short phrases that support your view. - -2. **Key Strategic Shifts**: What strategic priorities or shifts does management emphasise (e.g. capital allocation, growth drivers, new segments)? Be specific. - -3. **Major Hidden Risks**: From both Risk Factors and MD&A, what are the 3–4 most material risks that an investor might overlook? Cite the document. - -Use clear headings. Do not invent figures. Keep the response focused and under 800 words.""" - - full_content = f"""--- 10-K Excerpt (Item 1A + Item 7) ---\n\n{combined_text}\n\n---\n\n{user_prompt}""" - - try: - response = _generate_with_retry( - model, full_content, {"temperature": 0.3, "max_output_tokens": 4096} - ) - except Exception as api_err: - if _is_rate_limit_error(api_err): - raise RuntimeError("Rate limit exceeded. Please try again in a few minutes.") from api_err - raise - if not response or not response.text: - return "No analysis generated." - return response.text.strip() - - -def get_mda_comparative_insights( - api_key: str, - item1a_text: str, - item7_latest: str, - item7_3y_ago: Optional[str], - ticker: str, - sector: Optional[str] = None, - industry: Optional[str] = None, -) -> str: - """Comparative analysis: if item7_3y_ago provided, compare MD&As over 3 years; else single-year. Sector-aware: extract industry-specific Non-GAAP KPIs.""" - model = get_gemini_model(api_key) - sector_label = (sector or "N/A").strip() - industry_label = (industry or "N/A").strip() - kpi_instruction = ( - f" Given that this company is in the **{sector_label}** sector" - + (f" (industry: {industry_label})" if industry_label != "N/A" else "") - + ", meticulously scan the MD&A to find and extract **industry-specific Non-GAAP KPIs** " - "(e.g. Same-Store Sales Growth for Retail, ARR/NDR for Software, DAU/MAU for Tech). Present these hidden KPIs in a **clean markdown table** with columns such as KPI name, value, and period if stated." - ) - if not item7_3y_ago or not item7_3y_ago.strip(): - combined = [] - if item1a_text: - combined.append(clean_text_for_llm(item1a_text)) - if item7_latest: - combined.append(clean_text_for_llm(item7_latest)) - combined_text = "\n\n---\n\n".join(combined) - combined_text = smart_chunk(combined_text, max_chars=22000) - user_prompt = f"""You are a senior equity analyst. Use British English. -The text below is from the **latest 10-K only** for {ticker}: **Item 1A (Risk Factors)** and **Item 7 (MD&A)**. Provide a focused deep-dive report: - -1. **Management's Tone (Sentiment)**: Overall tone and 1–2 supporting phrases. -2. **Current Strategy & Priorities**: Key strategic focus, capital allocation, growth drivers from this filing only. -3. **Major Hidden Risks**: From Item 1A and MD&A, the 3–4 most material risks investors might overlook. -4. **Forensic / Quality of Earnings**: Any red flags in MD&A (accounting caveats, one-offs, cash flow vs earnings, segment disclosure). If none material, say so briefly.{kpi_instruction} -**Token-saving (Item 3 / 9A):** If no material weaknesses, major lawsuits, or off-balance-sheet red flags, output exactly: "✅ No material red flags or special issues detected in Item 3 and 9A." -Use clear headings. Under 800 words.""" - full_content = f"""--- 10-K Excerpt (Latest Year) ---\n\n{combined_text}\n\n---\n\n{user_prompt}""" - else: - latest_clean = smart_chunk(clean_text_for_llm(item7_latest), max_chars=12000) - past_clean = smart_chunk(clean_text_for_llm(item7_3y_ago), max_chars=12000) - user_prompt = f"""You are a senior equity analyst. Use British English. -Below are **Item 7 (Management's Discussion and Analysis)** from the 10-K for {ticker}: **LATEST YEAR** and **THREE YEARS AGO**. Perform a **Comparative Analysis**. - -1. **Core strategy**: What has changed in the company's stated strategy, priorities, or capital allocation between then and now? -2. **Emerging risks**: What new risks appear in the latest MD&A that were absent or less prominent 3 years ago? -3. **Management's tone**: How has the overall tone (confidence, caution, optimism) shifted? Quote 1–2 phrases from each period if relevant. -4. **Industry-specific KPIs**:{kpi_instruction} -5. **Item 3 (Legal) & Item 9A (Internal Controls):** You must save output tokens. If there are no material weaknesses, no massive lawsuits, and no major off-balance sheet red flags, DO NOT generate a long explanation. Simply output exactly: "✅ No material red flags or special issues detected in Item 3 and 9A." and move on. - -Use clear headings. Do not invent figures. Keep the response focused and under 900 words.""" - full_content = f"""--- MD&A LATEST YEAR ---\n\n{latest_clean}\n\n--- MD&A THREE YEARS AGO ---\n\n{past_clean}\n\n---\n\n{user_prompt}""" - try: - response = _generate_with_retry( - model, full_content, {"temperature": 0.3, "max_output_tokens": 4096} - ) - except Exception as api_err: - if _is_rate_limit_error(api_err): - raise RuntimeError("Rate limit exceeded. Please try again in a few minutes.") from api_err - raise - if not response or not response.text: - return "No analysis generated." - return response.text.strip() - - -def _run_mda_analysis_background(ticker: str, api_key: str, sec_email: str) -> None: - """Run download + Gemini in background (latest 10-K only for speed). Store result or error in st.session_state.""" - try: - _, item1a, item7_latest = download_and_extract_item7_and_1a(ticker, sec_email) - si = get_sector_industry(ticker) - analysis = get_mda_comparative_insights( - api_key, item1a or "", item7_latest or "", None, ticker, - sector=si.get("sector"), industry=si.get("industry"), - ) - st.session_state["mda_analysis_result"] = analysis - st.session_state["mda_analysis_excerpt"] = ((item1a or "") + "\n\n---\n\n" + (item7_latest or ""))[:12000] - st.session_state["mda_analysis_error"] = None - except Exception as e: - st.session_state["mda_analysis_error"] = str(e) - st.session_state["mda_analysis_result"] = None - st.session_state["mda_analysis_excerpt"] = None - finally: - st.session_state["mda_analysis_running"] = False - st.session_state["mda_analysis_done"] = True - st.session_state["mda_analysis_ticker"] = ticker - - -def get_industry_outlook(api_key: str, industry_name: str, tickers: list) -> str: - """Gemini: Wall Street macro analyst-style Industry Outlook for the selected sector (12–18 months).""" - model = get_gemini_model(api_key) - ticker_list_str = ", ".join(str(t).upper() for t in tickers if t) - user_prompt = f"""Act as an elite Wall Street macro analyst. Provide a concise **Industry Outlook** report for the **{industry_name}** sector, which includes leading companies like {ticker_list_str}. - -Focus on: -1. **Macro trends** affecting this industry over the next 12–18 months. -2. **Major growth drivers** (e.g., AI, interest rates, consumer spending, regulation). -3. **Key headwinds or regulatory risks** that could impact valuations or growth. - -Use clear headings. Be specific but concise. Keep the response under 600 words.""" - full_content = user_prompt - try: - response = _generate_with_retry( - model, full_content, {"temperature": 0.4, "max_output_tokens": 2048} - ) - except Exception as api_err: - if _is_rate_limit_error(api_err): - raise RuntimeError("Rate limit exceeded. Please try again in a few minutes.") from api_err - raise - if not response or not response.text: - return "No industry outlook generated." - return response.text.strip() - - -# ---------- Financial data helpers (yahooquery primary, yfinance fallback) ---------- -def _safe_float(x) -> Optional[float]: - if x is None or (isinstance(x, float) and (x != x or pd.isna(x))): - return None - try: - return float(x) - except (TypeError, ValueError): - return None - - -# ---------- yahooquery: map to our index/column shape (index=line items, columns=dates) ---------- -# yahooquery returns DataFrame: rows = periods, columns = asOfDate, TotalRevenue, NetIncome, ... -# Use tuples for alternate column names so F-Score and Radar get correct values. -_INCOME_ROW_MAP = [ - ("Total Revenue", ("TotalRevenue", "OperatingRevenue", "TotalRevenue")), - ("Cost Of Revenue", ("CostOfRevenue", "ReconciledCostOfRevenue")), - ("Gross Profit", ("GrossProfit",)), - ("Operating Income", ("OperatingIncome", "EBIT", "TotalOperatingIncomeAsReported")), - ("Net Income", ("NetIncome", "NetIncomeCommonStockholders", "NetIncomeContinuousOperations", "DilutedNIAvailtoComStockholders")), - ("Operating Expense", ("OperatingExpense", "OperatingExpenses", "TotalExpenses")), - ("Interest Expense", ("InterestExpense", "InterestExpenseNonOperating")), - ("Research And Development Expenses", ("ResearchAndDevelopment", "ResearchAndDevelopmentExpenses")), -] -_BALANCE_ROW_MAP = [ - ("Total Assets", ("TotalAssets",)), - ("Total Stockholder Equity", ("StockholdersEquity", "CommonStockEquity", "TotalEquityGrossMinorityInterest")), - ("Total Liabilities", ("TotalLiabilitiesNetMinorityInterest", "TotalLiabilities")), - ("Current Assets", ("CurrentAssets",)), - ("Current Liabilities", ("CurrentLiabilities",)), - ("Long Term Debt", ("LongTermDebt", "LongTermDebtAndCapitalLeaseObligation")), - ("Total Debt", ("TotalDebt",)), - ("Share Issued", ("OrdinarySharesNumber", "ShareIssued", "BasicAverageShares", "DilutedAverageShares")), - ("Cash And Cash Equivalents", ("CashAndCashEquivalents", "CashCashEquivalentsAndShortTermInvestments", "EndCashPosition")), - ("Retained Earnings", ("RetainedEarnings",)), -] -_CASHFLOW_ROW_MAP = [ - ("Operating Cash Flow", ("OperatingCashFlow", "CashFromOperatingActivities")), - ("Capital Expenditure", ("CapitalExpenditure", "CapitalExpenditures")), -] - - -def _yq_df_to_our_shape(df: pd.DataFrame, row_map: list, date_col: str = "asOfDate") -> Optional[pd.DataFrame]: - """Convert yahooquery DataFrame (rows=periods, columns=line items) to our shape: index=line names, columns=dates.""" - if df is None or df.empty or date_col not in df.columns: - return None - df = df.dropna(subset=[date_col]).sort_values(date_col, ascending=False).head(5) - if df.empty: - return None - dates = df[date_col].astype(str).str[:10].tolist() - data = {} - for our_name, yq_col in row_map: - cols = (yq_col,) if isinstance(yq_col, str) else yq_col - val_col = next((c for c in cols if c in df.columns), None) - if val_col is None: - data[our_name] = [None] * len(dates) - continue - data[our_name] = [_safe_float(v) for v in df[val_col].tolist()] - out = pd.DataFrame(data, index=dates).T - out.columns = dates - return out - - -def _share_issued_from_yq_balance(df_bal: pd.DataFrame) -> Optional[pd.Series]: - """Try OrdinarySharesNumber then ShareIssued for shares outstanding in yahooquery balance.""" - if df_bal is None or df_bal.empty: - return None - for col in ("OrdinarySharesNumber", "ShareIssued"): - if col in df_bal.columns and "asOfDate" in df_bal.columns: - s = df_bal.set_index("asOfDate")[col].sort_index(ascending=False) - s.index = s.index.astype(str).str[:10] - return s.reindex(s.index) # keep as series with date index - return None - - -@st.cache_data(ttl=300) -def _get_annual_financials_balance_cashflow_yahooquery(ticker: str) -> tuple: - """Fetch income, balance, cash flow from yahooquery. Return (fin_df, bal_df, cf_df) with index=line items, columns=dates. TTM fallback if annual insufficient.""" - if not YQTicker or not ticker: - return (None, None, None) - try: - yq = YQTicker(ticker.upper()) - inc_a = yq.income_statement(frequency="a", trailing=False) - bal_a = yq.balance_sheet(frequency="a", trailing=False) - cf_a = yq.cash_flow(frequency="a", trailing=False) - if inc_a is None or inc_a.empty or bal_a is None or bal_a.empty: - inc_q = yq.income_statement(frequency="q", trailing=False) - bal_q = yq.balance_sheet(frequency="q", trailing=False) - cf_q = yq.cash_flow(frequency="q", trailing=False) - # Build TTM: need at least 2 periods for Piotroski/Radar; use last 4Q and previous 4Q when 8+ quarters - if inc_q is not None and not inc_q.empty and len(inc_q) >= 4: - ttm0 = inc_q.head(4).sum(numeric_only=True) - row0 = ttm0.to_dict() if hasattr(ttm0, "to_dict") else dict(ttm0) - row0["asOfDate"] = inc_q["asOfDate"].iloc[0] if "asOfDate" in inc_q.columns else "TTM0" - rows_inc = [row0] - if len(inc_q) >= 8: - ttm1 = inc_q.iloc[4:8].sum(numeric_only=True) - row1 = ttm1.to_dict() if hasattr(ttm1, "to_dict") else dict(ttm1) - row1["asOfDate"] = inc_q["asOfDate"].iloc[4] if "asOfDate" in inc_q.columns else "TTM1" - rows_inc.append(row1) - inc_a = pd.DataFrame(rows_inc) - if bal_q is not None and not bal_q.empty: - bal_a = bal_q.head(2) if (bal_a is None or bal_a.empty) else bal_a - if cf_q is not None and not cf_q.empty and len(cf_q) >= 4 and (cf_a is None or cf_a.empty): - ttm0_cf = cf_q.head(4).sum(numeric_only=True) - row0_cf = ttm0_cf.to_dict() if hasattr(ttm0_cf, "to_dict") else dict(ttm0_cf) - row0_cf["asOfDate"] = cf_q["asOfDate"].iloc[0] if "asOfDate" in cf_q.columns else "TTM0" - rows_cf = [row0_cf] - if len(cf_q) >= 8: - ttm1_cf = cf_q.iloc[4:8].sum(numeric_only=True) - row1_cf = ttm1_cf.to_dict() if hasattr(ttm1_cf, "to_dict") else dict(ttm1_cf) - row1_cf["asOfDate"] = cf_q["asOfDate"].iloc[4] if "asOfDate" in cf_q.columns else "TTM1" - rows_cf.append(row1_cf) - cf_a = pd.DataFrame(rows_cf) - fin_df = _yq_df_to_our_shape(inc_a, _INCOME_ROW_MAP) - bal_df = _yq_df_to_our_shape(bal_a, _BALANCE_ROW_MAP) - if bal_df is not None and "Share Issued" not in bal_df.index and bal_a is not None and not bal_a.empty: - for sh_col in ("OrdinarySharesNumber", "ShareIssued"): - if sh_col in bal_a.columns: - row = {"Share Issued": [_safe_float(bal_a[sh_col].iloc[0])]} - if bal_df is not None and not bal_df.empty: - d = str(bal_a["asOfDate"].iloc[0])[:10] if "asOfDate" in bal_a.columns else bal_df.columns[0] - extra = pd.DataFrame(row, index=[d]).T - extra.columns = [d] - bal_df = pd.concat([bal_df, extra], axis=0) - break - cf_df = _yq_df_to_our_shape(cf_a, _CASHFLOW_ROW_MAP) - return (fin_df, bal_df, cf_df) - except Exception: - return (None, None, None) - - -# ---------- Raw statements & FCF = OCF - CapEx ---------- -def _get_row_series(df: pd.DataFrame, *names: str) -> Optional[pd.Series]: - if df is None or df.empty: - return None - for name in names: - try: - if name in df.index: - return df.loc[name].copy() - except (KeyError, TypeError): - continue - return None - - -def _fin_or_bal_empty(df) -> bool: - """True if DataFrame is missing, empty, or has no columns (e.g. yfinance returned empty).""" - return df is None or df.empty or (hasattr(df, "columns") and len(df.columns) == 0) - - -@st.cache_data(ttl=300) -def _get_annual_financials_balance_cashflow(ticker: str) -> tuple: - """Return (fin_df, bal_df, cf_df). Uses yahooquery first; if missing/fail, falls back to yfinance with TTM when needed.""" - if not ticker: - return (None, None, None) - fin_df, bal_df, cf_df = _get_annual_financials_balance_cashflow_yahooquery(ticker) - if fin_df is not None and not fin_df.empty and bal_df is not None and not bal_df.empty: - return (fin_df, bal_df, cf_df) - if not yf: - return (None, None, None) - try: - t = yf.Ticker(ticker.upper()) - fin = getattr(t, "financials", None) - bal = getattr(t, "balance_sheet", None) - cf = getattr(t, "cashflow", None) - if _fin_or_bal_empty(fin): - qf = getattr(t, "quarterly_financials", None) - if qf is not None and not qf.empty: - n = len(qf.columns) - if n >= 8: - c0 = qf.iloc[:, :4].sum(axis=1) - c1 = qf.iloc[:, 4:8].sum(axis=1) - fin = pd.concat([c0, c1], axis=1) - fin.columns = ["TTM0", "TTM1"] - elif n >= 5: - c0 = qf.iloc[:, :4].sum(axis=1) - c1 = qf.iloc[:, 4:n].sum(axis=1) - fin = pd.concat([c0, c1], axis=1) - fin.columns = ["TTM0", "TTM1"] - else: - fin = qf.iloc[:, : min(4, n)].sum(axis=1).to_frame("TTM0") - if _fin_or_bal_empty(bal): - qb = getattr(t, "quarterly_balance_sheet", None) - if qb is not None and not qb.empty: - n = len(qb.columns) - bal = qb.iloc[:, : min(2, n)].copy() - if bal.shape[1] == 1: - bal.columns = ["B0"] - else: - bal.columns = ["B0", "B1"] - if _fin_or_bal_empty(cf): - qc = getattr(t, "quarterly_cashflow", None) - if qc is not None and not qc.empty: - n = len(qc.columns) - cf = qc.iloc[:, : min(4, n)].sum(axis=1).to_frame("TTM0") - return (fin, bal, cf) - except Exception: - return (None, None, None) - - -@st.cache_data(ttl=300) -def get_sector_industry(ticker: str) -> dict: - """Return sector and industry from yfinance. Fallback to N/A.""" - if not yf: - return {"sector": "N/A", "industry": "N/A"} - try: - t = yf.Ticker(ticker.upper()) - info = t.info or {} - sector = (info.get("sector") or info.get("sectorDisp") or "N/A").strip() or "N/A" - industry = (info.get("industry") or info.get("industryDisp") or "N/A").strip() or "N/A" - return {"sector": sector, "industry": industry} - except Exception: - return {"sector": "N/A", "industry": "N/A"} - - -@st.cache_data(ttl=300) -def get_5yr_financial_trend(ticker: str) -> pd.DataFrame: - """Extract up to 5 years: Revenue, Net Income, Operating Margin, FCF (OCF - CapEx). Handles missing years.""" - if not yf: - return pd.DataFrame() - try: - t = yf.Ticker(ticker.upper()) - financials = t.financials # annual - cashflow = t.cashflow - if financials is None or financials.empty or cashflow is None or cashflow.empty: - return pd.DataFrame() - dates = sorted(financials.columns.tolist(), reverse=True)[:5] - ocf = _get_row_series(cashflow, "Operating Cash Flow", "Cash From Operating Activities", "Cash From Operations") - capx = _get_row_series(cashflow, "Capital Expenditure", "Capital Expenditures", "Purchase Of Property Plant And Equipment") - revenue = _get_row_series(financials, "Total Revenue", "Revenue", "Net Revenue") - ni = _get_row_series(financials, "Net Income", "Net Income Common Stockholders") - op_income = _get_row_series(financials, "Operating Income", "EBIT") - rows = [] - cashflow_cols = list(cashflow.columns) if cashflow is not None else [] - for d in dates: - yr = d.year if hasattr(d, "year") else int(str(d)[:4]) - rev = _safe_float(revenue.get(d)) if revenue is not None and d in revenue.index else None - net_i = _safe_float(ni.get(d)) if ni is not None and d in ni.index else None - op_i = _safe_float(op_income.get(d)) if op_income is not None and d in op_income.index else None - oper_margin = (op_i / rev * 100) if (op_i is not None and rev and rev != 0) else ((net_i / rev * 100) if (net_i is not None and rev and rev != 0) else None) - ocf_val = _safe_float(ocf.get(d)) if ocf is not None and d in ocf.index else None - if ocf_val is None and ocf is not None and cashflow_cols: - for c in cashflow_cols: - if (getattr(c, "year", None) or int(str(c)[:4])) == yr: - ocf_val = _safe_float(ocf.get(c)) - break - capx_val = _safe_float(capx.get(d)) if capx is not None and d in capx.index else None - if capx_val is None and capx is not None and cashflow_cols: - for c in cashflow_cols: - if (getattr(c, "year", None) or int(str(c)[:4])) == yr: - capx_val = _safe_float(capx.get(c)) - break - if ocf_val is not None and capx_val is not None: - fcf = ocf_val - capx_val - elif ocf_val is not None: - fcf = ocf_val - else: - fcf = None - rows.append({ - "Year": yr, - "Revenue": rev, - "Net Income": net_i, - "Operating Margin %": round(oper_margin, 2) if oper_margin is not None else None, - "FCF": fcf, - }) - return pd.DataFrame(rows) - except Exception: - return pd.DataFrame() - - -def _format_shares_display(shares: float) -> str: - """Format share count for UI, e.g. 15.42B Shares or 1.2B Shares.""" - if shares is None or shares <= 0: - return "N/A" - s = float(shares) - if s >= 1e9: - return f"{s / 1e9:.2f}B Shares" - if s >= 1e6: - return f"{s / 1e6:.2f}M Shares" - if s >= 1e3: - return f"{s / 1e3:.2f}K Shares" - return f"{s:.0f} Shares" - - -@st.cache_data(ttl=300) -def get_dcf_inputs(ticker: str) -> dict: - """FCF, Cash, Total Debt, Shares: from yahooquery (via _get_annual_financials) or yfinance fallback.""" - out = {"fcf": None, "total_debt": 0.0, "cash": 0.0, "shares": None} - if not ticker: - return out - try: - fin, bal, cf = _get_annual_financials_balance_cashflow(ticker) - if bal is not None and not bal.empty and cf is not None and not cf.empty: - sh = _get_row_series(bal, "Share Issued") - out["shares"] = _safe_float(sh.iloc[0]) if sh is not None and len(sh) > 0 else None - td = _get_row_series(bal, "Total Debt") - out["total_debt"] = float(td.iloc[0] or 0) if td is not None and len(td) > 0 else 0.0 - cash_s = _get_row_series(bal, "Cash And Cash Equivalents") - out["cash"] = float(cash_s.iloc[0] or 0) if cash_s is not None and len(cash_s) > 0 else 0.0 - ocf = _get_row_series(cf, "Operating Cash Flow") - capx = _get_row_series(cf, "Capital Expenditure") - if ocf is not None and len(ocf) > 0: - ocf_val = _safe_float(ocf.iloc[0]) - capx_val = _safe_float(capx.iloc[0]) if capx is not None and len(capx) > 0 else 0.0 - if ocf_val is not None: - out["fcf"] = ocf_val - (capx_val or 0) - if out.get("fcf") is not None or out.get("shares") is not None: - return out - except Exception: - pass - if not yf: - return out - try: - t = yf.Ticker(ticker.upper()) - info = t.info or {} - fast_info = getattr(t, "fast_info", None) - cashflow = getattr(t, "cashflow", None) - if cashflow is None or cashflow.empty: - cashflow = getattr(t, "quarterly_cashflow", None) - balance = getattr(t, "balance_sheet", None) - if balance is None or balance.empty: - balance = getattr(t, "quarterly_balance_sheet", None) - - # ----- Shares Outstanding: multi-step fallback (no manual by default) ----- - shares = None - if fast_info is not None: - try: - s = getattr(fast_info, "shares", None) - if s is None and hasattr(fast_info, "get"): - s = fast_info.get("shares") - if s is not None and float(s) > 0: - shares = float(s) - except (TypeError, ValueError, AttributeError): - pass - if shares is None: - for key in ("sharesOutstanding", "Shares Outstanding", "impliedSharesOutstanding", "Float Shares"): - s = info.get(key) - if s is not None and float(s) > 0: - shares = float(s) - break - if shares is None and balance is not None and not balance.empty: - try: - if "Share Issued" in balance.index: - shares = _safe_float(balance.loc["Share Issued"].iloc[0]) - if (shares is None or shares <= 0) and "Ordinary Shares Number" in balance.index: - shares = _safe_float(balance.loc["Ordinary Shares Number"].iloc[0]) - except (KeyError, TypeError, IndexError): - pass - out["shares"] = shares if (shares is not None and shares > 0) else None - - # ----- Total Debt: fast_info → info → balance ----- - total_debt = None - if fast_info is not None: - try: - d = getattr(fast_info, "total_debt", None) or (fast_info.get("total_debt") if hasattr(fast_info, "get") else None) - if d is not None and float(d) >= 0: - total_debt = float(d) - except (TypeError, ValueError, AttributeError): - pass - if total_debt is None: - total_debt = info.get("Total Debt") - if total_debt is None and balance is not None and not balance.empty: - try: - if "Total Debt" in balance.index: - total_debt = _safe_float(balance.loc["Total Debt"].iloc[0]) - except (KeyError, TypeError, IndexError): - pass - out["total_debt"] = float(total_debt) if total_debt is not None else 0.0 - - # ----- Cash: fast_info → info → balance ----- - cash = None - if fast_info is not None: - try: - c = getattr(fast_info, "cash", None) or (fast_info.get("cash") if hasattr(fast_info, "get") else None) - if c is not None and float(c) >= 0: - cash = float(c) - except (TypeError, ValueError, AttributeError): - pass - if cash is None: - cash = info.get("Cash And Cash Equivalents") or info.get("Cash") - if cash is None and balance is not None and not balance.empty: - try: - for row in ("Cash And Cash Equivalents", "Cash Cash Equivalents And Short Term Investments", "Cash"): - if row in balance.index: - cash = _safe_float(balance.loc[row].iloc[0]) - if cash is not None: - break - except (KeyError, TypeError, IndexError): - pass - out["cash"] = float(cash) if cash is not None else 0.0 - - # ----- Base FCF = OCF - CapEx ----- - ocf = _get_row_series(cashflow, "Operating Cash Flow", "Cash From Operating Activities", "Cash From Operations") if cashflow is not None else None - capx = _get_row_series(cashflow, "Capital Expenditure", "Capital Expenditures", "Purchase Of Property Plant And Equipment") if cashflow is not None else None - if ocf is not None and len(ocf) > 0: - latest_date = ocf.index[0] - ocf_val = _safe_float(ocf.iloc[0]) - capx_val = _safe_float(capx.get(latest_date)) if (capx is not None and hasattr(capx, "index") and latest_date in getattr(capx, "index", [])) else (_safe_float(capx.iloc[0]) if capx is not None and len(capx) > 0 else None) - if capx_val is None: - capx_val = 0.0 - if ocf_val is not None: - latest_fcf = ocf_val - capx_val - if latest_fcf == latest_fcf and not (isinstance(latest_fcf, float) and pd.isna(latest_fcf)): - out["fcf"] = latest_fcf - return out - except Exception: - return out - - -def dcf_intrinsic_value(fcf: float, wacc: float, terminal_growth: float, fcf_growth: float, years: int = 5) -> float: - """5-year DCF: project FCF with fcf_growth, then terminal value; discount at WACC. Returns enterprise value. Robust: avoids div by zero.""" - if fcf is None or fcf <= 0: - return 0.0 - if wacc <= terminal_growth or wacc <= 0: - return 0.0 - pv = 0.0 - fcft = float(fcf) - for t in range(1, years + 1): - pv += fcft / ((1 + wacc) ** t) - fcft *= (1 + fcf_growth) - terminal_fcf = fcft - tv = terminal_fcf * (1 + terminal_growth) / (wacc - terminal_growth) - pv += tv / ((1 + wacc) ** years) - return pv - - -def dcf_10y_2stage(fcf: float, wacc: float, term_growth: float, fcf_growth: float) -> float: - """10-Year 2-Stage DCF. Stage 1 (Y1–5): FCF grows at fcf_growth. Stage 2 (Y6–10): growth linearly fades from fcf_growth to term_growth by Y10. TV at Y10; discount all to PV.""" - if fcf is None or fcf <= 0: - return 0.0 - if wacc <= term_growth or wacc <= 0: - return 0.0 - pv = 0.0 - fcft = float(fcf) - for t in range(1, 6): - pv += fcft / ((1 + wacc) ** t) - fcft *= (1 + fcf_growth) - for t in range(6, 11): - fade = (t - 6) / 4.0 - g_t = fcf_growth + fade * (term_growth - fcf_growth) - fcft *= (1 + g_t) - pv += fcft / ((1 + wacc) ** t) - tv = fcft * (1 + term_growth) / (wacc - term_growth) - pv += tv / ((1 + wacc) ** 10) - return pv - - -def excel_style_dcf(fcf_base: float, wacc: float, term_growth: float, fcf_growth: float, total_debt: float, cash: float, shares: float) -> dict: - """10Y 2-Stage DCF: EV = PV(FCF Y1–10) + PV(TV); Equity = EV - Debt + Cash; Value per share = Equity / Shares.""" - ev = dcf_10y_2stage(fcf_base, wacc, term_growth, fcf_growth) - equity = ev - total_debt + cash - shares_safe = float(shares) if (shares is not None and float(shares) > 0) else None - value_per_share = (equity / shares_safe) if shares_safe else None - return {"ev": ev, "equity_value": equity, "value_per_share": value_per_share, "shares": shares_safe} - - -# Aswath Damodaran sector WACC (approx. 2024/2025 baseline). Used for reference in DCF panel. -DAMODARAN_WACC = { - "Software": 8.5, - "Retail": 7.5, - "Hardware": 9.0, - "Financials": 8.0, - "Healthcare": 7.2, - "Consumer": 7.5, - "Technology": 8.5, - "Industrial": 7.8, - "Energy": 8.2, - "Utilities": 6.5, -} -DAMODARAN_ERP_PCT = 4.6 -DAMODARAN_RF_PCT = 4.2 - - -def _damodaran_wacc_for_sector(sector: str) -> float: - """Map yfinance sector string to closest Damodaran WACC. Default 8.0%.""" - if not sector: - return 8.0 - s = (sector or "").lower() - if "software" in s or "technology" in s or "internet" in s: - return DAMODARAN_WACC.get("Software", 8.5) - if "hardware" in s or "semiconductor" in s: - return DAMODARAN_WACC.get("Hardware", 9.0) - if "retail" in s or "consumer" in s or "cyclical" in s: - return DAMODARAN_WACC.get("Retail", 7.5) - if "financial" in s or "bank" in s or "insurance" in s: - return DAMODARAN_WACC.get("Financials", 8.0) - if "health" in s or "pharma" in s: - return DAMODARAN_WACC.get("Healthcare", 7.2) - if "industrial" in s: - return DAMODARAN_WACC.get("Industrial", 7.8) - if "energy" in s or "oil" in s: - return DAMODARAN_WACC.get("Energy", 8.2) - if "utilities" in s: - return DAMODARAN_WACC.get("Utilities", 6.5) - return 8.0 - - -@st.cache_data(ttl=300) -def get_analyst_consensus(ticker: str) -> dict: - """Fetch analyst consensus from yfinance: targetMeanPrice, recommendationKey, revenueGrowth, earningsGrowth. Missing → N/A.""" - out = {"targetMeanPrice": "N/A", "recommendationKey": "N/A", "revenueGrowth": "N/A", "earningsGrowth": "N/A"} - if not yf or not ticker: - return out - try: - t = yf.Ticker(ticker.upper()) - info = t.info or {} - tp = info.get("targetMeanPrice") - if tp is not None: - try: - out["targetMeanPrice"] = f"${float(tp):.2f}" - except (TypeError, ValueError): - out["targetMeanPrice"] = str(tp) - rec = info.get("recommendationKey") or info.get("recommendation") - if rec is not None: - out["recommendationKey"] = str(rec) - rg = info.get("revenueGrowth") - if rg is not None: - try: - out["revenueGrowth"] = f"{float(rg) * 100:.1f}%" - except (TypeError, ValueError): - out["revenueGrowth"] = str(rg) - eg = info.get("earningsGrowth") - if eg is not None: - try: - out["earningsGrowth"] = f"{float(eg) * 100:.1f}%" - except (TypeError, ValueError): - out["earningsGrowth"] = str(eg) - return out - except Exception: - return out - - -@st.cache_data(ttl=300) -def get_dcf_smart_defaults(ticker: str) -> dict: - """Smart default assumptions: WACC from CAPM (Beta), Terminal Growth = 2.5%, FCF Growth from revenueGrowth/earningsGrowth or 8%.""" - out = {"wacc_pct": 10.0, "term_growth_pct": 2.5, "fcf_growth_pct": 8.0} - if not yf or not ticker: - return out - try: - t = yf.Ticker(ticker.upper()) - info = t.info or {} - beta = info.get("beta") - if beta is None: - beta = 1.0 - else: - try: - beta = float(beta) - except (TypeError, ValueError): - beta = 1.0 - risk_free = 4.0 - market_risk_premium = 5.0 - calculated_wacc = risk_free + (beta * market_risk_premium) - out["wacc_pct"] = round(min(20.0, max(4.0, calculated_wacc)), 1) - out["term_growth_pct"] = 2.5 - rev_growth = info.get("revenueGrowth") or info.get("earningsGrowth") - if rev_growth is not None: - try: - g = float(rev_growth) - out["fcf_growth_pct"] = round(min(30.0, max(-10.0, g * 100)), 1) - except (TypeError, ValueError): - pass - return out - except Exception: - return out - - -# ---------- yfinance: Comps (multiples) ---------- -@st.cache_data(ttl=300) -def get_comps_data(tickers: tuple) -> pd.DataFrame: - """Fetch Forward P/E, EV/EBITDA, P/B using forwardPE, enterpriseToEbitda, priceToBook. Missing → None (display as N/A). Robust per-ticker error handling.""" - if not yf: - return pd.DataFrame() - rows = [] - for sym in tickers: - sym = str(sym).strip().upper() - if not sym: - continue - try: - t = yf.Ticker(sym) - info = t.info or {} - forward_pe = info.get("forwardPE") or info.get("Forward PE") or info.get("trailingPE") or info.get("Trailing PE") - ev_ebitda = info.get("enterpriseToEbitda") - if ev_ebitda is None: - ev, ebitda = info.get("enterpriseValue"), info.get("ebitda") - if ev is not None and ebitda is not None and ebitda != 0: - ev_ebitda = ev / ebitda - pb = info.get("priceToBook") or info.get("Price To Book") - rows.append({ - "Ticker": sym, - "Forward P/E": round(float(forward_pe), 2) if forward_pe is not None and _safe_float(forward_pe) is not None else None, - "EV/EBITDA": round(float(ev_ebitda), 2) if ev_ebitda is not None and _safe_float(ev_ebitda) is not None else None, - "P/B": round(float(pb), 2) if pb is not None and _safe_float(pb) is not None else None, - }) - except Exception: - rows.append({"Ticker": sym, "Forward P/E": None, "EV/EBITDA": None, "P/B": None}) - if not rows: - return pd.DataFrame() - return pd.DataFrame(rows) - - -# ---------- DuPont, Altman Z, Red Flags, YoY (2–3 years) ---------- -def _na(x): - """Return N/A for None/NaN, else value (for display).""" - if x is None or (isinstance(x, float) and (pd.isna(x) or x != x)): - return "N/A" - return x - - -@st.cache_data(ttl=300) -def get_dupont_altman_redflags_yoy(ticker: str) -> dict: - """Returns DuPont (3-step ROE), Altman Z-Score, red flags, YoY. Uses yahooquery then yfinance with TTM fallback.""" - try: - fin, bal, _ = _get_annual_financials_balance_cashflow(ticker) - if fin is None or fin.empty or bal is None or bal.empty: - return {} - t = yf.Ticker(ticker.upper()) - info = t.info or {} - # TTM columns: keep order TTM0 (current), TTM1 (prior). Else use date sort (newest first). - col_list = fin.columns.tolist() - if col_list and str(col_list[0]).startswith("TTM"): - dates = col_list[:3] - else: - dates = sorted(col_list, reverse=True)[:3] - if not dates: - return {} - rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue") - ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders") - ebit = _get_row_series(fin, "Operating Income", "EBIT") - gross = _get_row_series(fin, "Gross Profit") - interest = _get_row_series(fin, "Interest Expense", "Interest Expense Net") - total_assets = _get_row_series(bal, "Total Assets") - total_equity = _get_row_series(bal, "Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest") - current_assets = _get_row_series(bal, "Current Assets") - current_liab = _get_row_series(bal, "Current Liabilities") - retained = _get_row_series(bal, "Retained Earnings") - total_liab = _get_row_series(bal, "Total Liabilities") - market_cap = info.get("marketCap") or info.get("Market Cap") - def _v(s, d): - if s is None or d not in s.index: - return None - return _safe_float(s.get(d)) - rows = [] - for i, d in enumerate(dates): - yr = int(str(d)[:4]) if (isinstance(d, str) and str(d)[:4].isdigit()) else (d.year if hasattr(d, "year") else (2024 - i)) - r = _v(rev, d) - net_i = _v(ni, d) - ta = _v(total_assets, d) - te = _v(total_equity, d) - if ta and ta > 0 and te and te > 0 and r and r != 0: - npm = (net_i / r * 100) if net_i is not None else None - at = r / ta if r and ta else None - em = ta / te if ta and te else None - roe = (net_i / te * 100) if (net_i and te) else (npm * at * em / 100 if (npm and at and em) else None) - else: - npm = at = em = roe = None - gross_p = _v(gross, d) - gross_margin = (gross_p / r * 100) if (gross_p and r and r != 0) else None - op_inc = _v(ebit, d) - op_margin = (op_inc / r * 100) if (op_inc and r and r != 0) else None - ca = _v(current_assets, d) - cl = _v(current_liab, d) - current_ratio = (ca / cl) if (ca and cl and cl != 0) else None - int_exp = _v(interest, d) - if op_inc is not None and int_exp is not None and int_exp != 0: - _ic = op_inc / int_exp - interest_cov = round(_ic, 2) if (_ic == _ic and not (isinstance(_ic, float) and (pd.isna(_ic) or _ic != _ic))) else None - else: - interest_cov = None # N/A when Interest Expense is 0 or missing (avoid nan%) - rows.append({ - "Year": yr, - "Revenue": r, "Net Income": net_i, - "NPM %": round(npm, 2) if npm is not None else None, - "Asset Turnover": round(at, 4) if at is not None else None, - "Equity Mult.": round(em, 2) if em is not None else None, - "ROE %": round(roe, 2) if roe is not None else None, - "Gross Margin %": round(gross_margin, 2) if gross_margin is not None else None, - "Operating Margin %": round(op_margin, 2) if op_margin is not None else None, - "Current Ratio": round(current_ratio, 2) if current_ratio is not None else None, - "Interest Coverage": interest_cov, - }) - dupont_df = pd.DataFrame(rows) - yoy = [] - if len(dupont_df) >= 2: - for col in ["NPM %", "ROE %", "Gross Margin %", "Operating Margin %", "Current Ratio", "Interest Coverage"]: - if col not in dupont_df.columns: - continue - cur = dupont_df[col].iloc[0] - prev = dupont_df[col].iloc[1] - if cur is not None and prev is not None and prev != 0 and not (pd.isna(cur) or pd.isna(prev)): - if "Margin" in col or "NPM" in col or "ROE" in col: - chg_pp = (cur - prev) # percentage point change (e.g. 7.0 = 7%) - if pd.isna(chg_pp) or chg_pp != chg_pp: - continue - yoy.append({"Ratio": col, "Latest": cur, "Prior": prev, "YoY (pp)": round(chg_pp, 2), "Comment": f"{'Improved' if chg_pp > 0 else 'Declined'} by {abs(chg_pp):.1f}% YoY"}) - else: - pct = (cur - prev) / abs(prev) * 100 - if pd.isna(pct) or pct != pct: - continue - yoy.append({"Ratio": col, "Latest": cur, "Prior": prev, "YoY %": round(pct, 1), "Comment": f"{'Up' if pct > 0 else 'Down'} {abs(round(pct, 1))}% YoY"}) - latest_bal_d = bal.columns[0] - wc = (_v(current_assets, latest_bal_d) or 0) - (_v(current_liab, latest_bal_d) or 0) - ta_l = _v(total_assets, latest_bal_d) - re_l = _v(retained, latest_bal_d) - tl_l = _v(total_liab, latest_bal_d) - ebit_l = _v(ebit, fin.columns[0]) - sales_l = _v(rev, fin.columns[0]) - altman_z = None - if ta_l and ta_l > 0 and market_cap is not None and tl_l and tl_l != 0 and sales_l: - a = wc / ta_l - b = (re_l or 0) / ta_l - c = (ebit_l or 0) / ta_l - d = market_cap / tl_l - e = sales_l / ta_l - altman_z = 1.2 * a + 1.4 * b + 3.3 * c + 0.6 * d + 1.0 * e - red_flags = [] - if len(dupont_df) > 0: - row0 = dupont_df.iloc[0] - cr = row0.get("Current Ratio") - if cr is not None and cr < 1.0: - red_flags.append({"metric": "Current Ratio", "value": cr, "threshold": 1.0, "flag": "WARNING", "comment": "Current assets do not cover current liabilities; liquidity risk."}) - ic = row0.get("Interest Coverage") - if ic is not None and ic < 1.5: - red_flags.append({"metric": "Interest Coverage", "value": ic, "threshold": 1.5, "flag": "WARNING", "comment": "EBIT barely covers interest; default risk."}) - return { - "dupont": dupont_df, - "yoy": yoy, - "altman_z": round(altman_z, 2) if altman_z is not None else None, - "red_flags": red_flags, - } - except (KeyError, TypeError, ZeroDivisionError, IndexError) as e: - return {} - except Exception: - return {} - - -@st.cache_data(ttl=300) -def get_quarterly_momentum(ticker: str) -> dict: - """Last 4 quarters Revenue and Net Income from quarterly_financials; QoQ growth for most recent quarter. Returns {df, qoq_revenue_pct, qoq_ni_pct} or empty.""" - out = {"df": None, "qoq_revenue_pct": None, "qoq_ni_pct": None} - if not yf or not ticker: - return out - try: - t = yf.Ticker(ticker.upper()) - qfin = getattr(t, "quarterly_financials", None) - if qfin is None or qfin.empty or len(qfin.columns) < 2: - return out - rev = _get_row_series(qfin, "Total Revenue", "Revenue", "Net Revenue") - ni = _get_row_series(qfin, "Net Income", "Net Income Common Stockholders") - if rev is None and ni is None: - return out - cols = list(qfin.columns)[:4] - rows = [] - for c in cols: - try: - if hasattr(c, "strftime"): - q = (c.month - 1) // 3 + 1 if hasattr(c, "month") else 1 - label = c.strftime("%Y") + f"-Q{q}" - else: - label = str(c)[:12] - except Exception: - label = str(c)[:12] - r_val = _safe_float(rev.loc[c]) if rev is not None and c in rev.index else None - n_val = _safe_float(ni.loc[c]) if ni is not None and c in ni.index else None - rows.append({"Quarter": label, "Revenue": r_val, "Net Income": n_val}) - out["df"] = pd.DataFrame(rows) - if len(rows) >= 2: - r0, r1 = rows[0].get("Revenue"), rows[1].get("Revenue") - n0, n1 = rows[0].get("Net Income"), rows[1].get("Net Income") - if r0 is not None and r1 is not None and r1 != 0: - out["qoq_revenue_pct"] = round((r0 - r1) / abs(r1) * 100, 1) - if n0 is not None and n1 is not None and n1 != 0: - out["qoq_ni_pct"] = round((n0 - n1) / abs(n1) * 100, 1) - return out - except Exception: - return out - - -@st.cache_data(ttl=300) -def get_quarterly_ratio_changes(ticker: str) -> list: - """QoQ ratio changes: NPM %, ROE %, Gross Margin %, Operating Margin %, Current Ratio, Interest Coverage. Latest quarter vs previous. Returns list of {Metric, Current, Change, Trend}.""" - out = [] - if not yf or not ticker: - return out - try: - t = yf.Ticker(ticker.upper()) - qf = getattr(t, "quarterly_financials", None) - qb = getattr(t, "quarterly_balance_sheet", None) - if qf is None or qf.empty or qb is None or qb.empty or len(qf.columns) < 2 or len(qb.columns) < 2: - return out - rev = _get_row_series(qf, "Total Revenue", "Revenue", "Net Revenue") - ni = _get_row_series(qf, "Net Income", "Net Income Common Stockholders") - gross = _get_row_series(qf, "Gross Profit") - ebit = _get_row_series(qf, "Operating Income", "EBIT") - interest = _get_row_series(qf, "Interest Expense", "Interest Expense Net") - ta = _get_row_series(qb, "Total Assets") - te = _get_row_series(qb, "Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest") - ca = _get_row_series(qb, "Current Assets") - cl = _get_row_series(qb, "Current Liabilities") - def v(s, col): - if s is None or col not in s.index: - return None - return _safe_float(s.get(col)) - c0, c1 = qf.columns[0], qf.columns[1] - b0, b1 = qb.columns[0], qb.columns[1] - r0, r1 = v(rev, c0), v(rev, c1) - n0, n1 = v(ni, c0), v(ni, c1) - g0, g1 = v(gross, c0), v(gross, c1) - e0, e1 = v(ebit, c0), v(ebit, c1) - i0, i1 = v(interest, c0), v(interest, c1) - ta0, ta1 = v(ta, b0), v(ta, b1) - te0, te1 = v(te, b0), v(te, b1) - ca0, ca1 = v(ca, b0), v(ca, b1) - cl0, cl1 = v(cl, b0), v(cl, b1) - npm0 = (n0 / r0 * 100) if (n0 is not None and r0 and r0 != 0) else None - npm1 = (n1 / r1 * 100) if (n1 is not None and r1 and r1 != 0) else None - roe0 = (n0 / te0 * 100) if (n0 is not None and te0 and te0 != 0) else None - roe1 = (n1 / te1 * 100) if (n1 is not None and te1 and te1 != 0) else None - gm0 = (g0 / r0 * 100) if (g0 is not None and r0 and r0 != 0) else None - gm1 = (g1 / r1 * 100) if (g1 is not None and r1 and r1 != 0) else None - om0 = (e0 / r0 * 100) if (e0 is not None and r0 and r0 != 0) else None - om1 = (e1 / r1 * 100) if (e1 is not None and r1 and r1 != 0) else None - cr0 = (ca0 / cl0) if (ca0 is not None and cl0 and cl0 != 0) else None - cr1 = (ca1 / cl1) if (ca1 is not None and cl1 and cl1 != 0) else None - ic0 = (e0 / i0) if (e0 is not None and i0 and i0 != 0) else None - ic1 = (e1 / i1) if (e1 is not None and i1 and i1 != 0) else None - def row(metric, cur, prev, is_pct_point=False): - if cur is None: - return None - if prev is None or (is_pct_point and prev != prev): - return {"Metric": metric, "Current Value": round(cur, 2), "Change": "—", "Trend": "—"} - if is_pct_point: - chg = cur - prev - else: - chg = ((cur - prev) / abs(prev) * 100) if prev != 0 else 0 - trend = "↑" if chg > 0 else ("↓" if chg < 0 else "—") - chg_str = f"{chg:+.1f}%" if not is_pct_point else f"{chg:+.1f} pp" - return {"Metric": metric, "Current Value": round(cur, 2), "Change": chg_str, "Trend": trend} - for name, cur, prev, is_pp in [ - ("NPM %", npm0, npm1, True), ("ROE %", roe0, roe1, True), ("Gross Margin %", gm0, gm1, True), - ("Operating Margin %", om0, om1, True), ("Current Ratio", cr0, cr1, False), ("Interest Coverage", ic0, ic1, False), - ]: - r = row(name, cur, prev, is_pp) - if r: - out.append(r) - return out - except Exception: - return out - - -@st.cache_data(ttl=300) -def get_income_statement_sankey_data(ticker: str) -> dict: - """Latest year (or TTM): Revenue, COGS, Gross Profit, OpEx, Operating Income, Tax/Interest/Other, Net Income. Uses yahooquery then yfinance.""" - out = {"revenue": 0, "cogs": 0, "gross_profit": 0, "opex": 0, "operating_income": 0, "tax_interest_other": 0, "net_income": 0} - fin, _, _ = _get_annual_financials_balance_cashflow(ticker) - if fin is None or fin.empty: - return out - try: - rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue") - cogs = _get_row_series(fin, "Cost Of Revenue", "Cost Of Goods Sold") - gross = _get_row_series(fin, "Gross Profit") - op_inc = _get_row_series(fin, "Operating Income", "EBIT") - ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders") - if rev is None or len(rev) == 0: - return out - d = rev.index[0] - revenue = abs(_safe_float(rev.get(d)) or 0) - cogs_val = abs(_safe_float(cogs.get(d)) if cogs is not None and d in cogs.index else 0) or 0 - gross_val = _safe_float(gross.get(d)) if gross is not None and d in gross.index else None - if gross_val is None and revenue and cogs_val is not None: - gross_val = revenue - cogs_val - elif gross_val is None: - gross_val = revenue - gross_val = abs(gross_val) if gross_val is not None else 0 - op_inc_val = _safe_float(op_inc.get(d)) if op_inc is not None and d in op_inc.index else None - op_inc_val = op_inc_val if op_inc_val is not None else 0 - ni_val = _safe_float(ni.get(d)) if ni is not None and d in ni.index else None - ni_val = ni_val if ni_val is not None else 0 - opex_val = max(0, gross_val - op_inc_val) if (gross_val >= op_inc_val) else 0 - tax_interest_other = max(0, op_inc_val - ni_val) if (op_inc_val - ni_val) > 0 else abs(min(0, op_inc_val - ni_val)) - out["revenue"] = max(revenue, 1) - out["cogs"] = min(cogs_val, revenue - 1e-6) - out["gross_profit"] = gross_val - out["opex"] = opex_val - out["operating_income"] = op_inc_val - out["tax_interest_other"] = tax_interest_other - out["net_income"] = ni_val - return out - except Exception: - return out - - -def _build_sankey_figure(data: dict) -> "go.Figure": - """Sankey: Revenue -> COGS + Gross Profit; Gross Profit -> OpEx + OpInc; OpInc -> Tax/Interest/Other + Net Income. Profit=green, Expense=red/grey.""" - if go is None: - return None - rev, cogs, gp, opex, opinc, tax_other, ni = ( - data["revenue"], data["cogs"], data["gross_profit"], data["opex"], - data["operating_income"], data["tax_interest_other"], data["net_income"], - ) - if rev <= 0: - return None - nodes = ["Total Revenue", "Cost of Revenue", "Gross Profit", "Operating Expenses", "Operating Income", "Tax/Interest/Other", "Net Income"] - node_colors = ["rgba(0,150,80,0.8)", "rgba(180,60,60,0.7)", "rgba(0,150,80,0.8)", "rgba(120,120,120,0.7)", "rgba(0,150,80,0.8)", "rgba(120,120,120,0.7)", "rgba(0,180,90,0.9)"] - source = [0, 0, 2, 2, 4, 4] - target = [1, 2, 3, 4, 5, 6] - value = [cogs, gp, opex, opinc, tax_other, ni] - value = [max(0, float(v)) for v in value] - fig = go.Figure(data=[go.Sankey( - node=dict(label=nodes, color=node_colors, pad=15, thickness=20), - link=dict(source=source, target=target, value=value), - )]) - fig.update_layout(title="Income Statement Flow (Latest Year)", height=400, margin=dict(t=40, b=20, l=20, r=20), font=dict(size=12)) - return fig - - -def sankey_data_from_ai(ai_dict: dict) -> dict: - """Build Sankey input dict from get_sec_financials_llm result (current_yr). Gross Profit = Revenue - CostOfRevenue; Operating Income = Gross Profit - OperatingExpenses.""" - out = {"revenue": 0, "cogs": 0, "gross_profit": 0, "opex": 0, "operating_income": 0, "tax_interest_other": 0, "net_income": 0} - cur = (ai_dict or {}).get("current_yr") or {} - revenue = max(0, (cur.get("Revenue") or 0)) - cogs = max(0, min(cur.get("CostOfRevenue") or 0, revenue - 1e-6)) - gross_profit = revenue - cogs - opex = max(0, cur.get("OperatingExpenses") or 0) - operating_income = gross_profit - opex - net_income = cur.get("NetIncome") or 0 - tax_interest_other = max(0, operating_income - net_income) if operating_income > net_income else abs(min(0, operating_income - net_income)) - out["revenue"] = max(revenue, 1) - out["cogs"] = cogs - out["gross_profit"] = gross_profit - out["opex"] = opex - out["operating_income"] = operating_income - out["tax_interest_other"] = tax_interest_other - out["net_income"] = net_income - return out - - -def piotroski_from_ai(ai_dict: dict) -> dict: - """Piotroski F-Score (0-9) from AI-extracted current_yr vs previous_yr. Returns {score, criteria, used_ttm: True}.""" - out = {"score": 0, "criteria": [], "used_ttm": True} - cur = (ai_dict or {}).get("current_yr") or {} - prev = (ai_dict or {}).get("previous_yr") or {} - if not cur: - return out - def v(d, k): return (d.get(k) or 0) - ni0, ni1 = v(cur, "NetIncome"), v(prev, "NetIncome") - ocf0 = v(cur, "OperatingCashFlow") - ta0, ta1 = v(cur, "TotalAssets"), v(prev, "TotalAssets") - roa0 = (ni0 / ta0 * 100) if ta0 and ta0 != 0 else None - roa1 = (ni1 / ta1 * 100) if ta1 and ta1 != 0 else None - c1 = ni0 > 0 - c2 = ocf0 > 0 - c3 = (roa0 is not None and roa1 is not None and roa0 > roa1) - c4 = ocf0 > ni0 - lt0, lt1 = v(cur, "LongTermDebt"), v(prev, "LongTermDebt") - c5 = (ta0 and ta1 and (lt0 / ta0) < (lt1 / ta1)) if ta0 and ta1 else False - ca0, ca1 = v(cur, "CurrentAssets"), v(prev, "CurrentAssets") - cl0, cl1 = v(cur, "CurrentLiabilities"), v(prev, "CurrentLiabilities") - cr0 = (ca0 / cl0) if cl0 and cl0 != 0 else None - cr1 = (ca1 / cl1) if cl1 and cl1 != 0 else None - c6 = (cr0 is not None and cr1 is not None and cr0 > cr1) - sh0, sh1 = v(cur, "SharesOutstanding"), v(prev, "SharesOutstanding") - c7 = (sh0 <= sh1) if (sh0 and sh1) else True - rev0, rev1 = v(cur, "Revenue"), v(prev, "Revenue") - gm0 = ((rev0 - v(cur, "CostOfRevenue")) / rev0 * 100) if rev0 and rev0 != 0 else None - gm1 = ((rev1 - v(prev, "CostOfRevenue")) / rev1 * 100) if rev1 and rev1 != 0 else None - c8 = (gm0 is not None and gm1 is not None and gm0 > gm1) - at0 = (rev0 / ta0) if rev0 and ta0 and ta0 != 0 else None - at1 = (rev1 / ta1) if rev1 and ta1 and ta1 != 0 else None - c9 = (at0 is not None and at1 is not None and at0 > at1) - criteria = [ - ("Net Income > 0 (profitability)", c1), - ("Operating Cash Flow > 0 (cash generative)", c2), - ("ROA increased vs prior period (improving returns)", c3), - ("OCF > Net Income (earnings quality, less accruals)", c4), - ("Leverage decreased: LT Debt/Assets lower (less debt)", c5), - ("Current Ratio improved (better liquidity)", c6), - ("No dilution: shares unchanged or lower (no equity raise)", c7), - ("Gross Margin improved (pricing power)", c8), - ("Asset Turnover improved (efficiency)", c9), - ] - out["score"] = sum(1 for _, p in criteria if p) - out["criteria"] = criteria - return out - - -def radar_metrics_from_ai(ai_dict: dict) -> dict: - """ROE, Current Ratio, Asset Turnover, Equity Mult, Revenue YoY from AI dict; normalized 0-100 for radar. Equity proxy: TotalAssets - CurrentLiabilities - LongTermDebt.""" - cur = (ai_dict or {}).get("current_yr") or {} - prev = (ai_dict or {}).get("previous_yr") or {} - if not cur: - return {} - eq0 = (cur.get("TotalAssets") or 0) - (cur.get("CurrentLiabilities") or 0) - (cur.get("LongTermDebt") or 0) - if eq0 <= 0: - eq0 = (cur.get("TotalAssets") or 0) * 0.5 - roe = (cur.get("NetIncome") or 0) / eq0 * 100 if eq0 else 0 - ca, cl = cur.get("CurrentAssets") or 0, cur.get("CurrentLiabilities") or 0 - current_ratio = (ca / cl) if cl and cl != 0 else 0 - ta = cur.get("TotalAssets") or 1 - asset_turnover = (cur.get("Revenue") or 0) / ta - equity_mult = (cur.get("TotalAssets") or 0) / eq0 if eq0 else 0 - rev0, rev1 = cur.get("Revenue") or 0, prev.get("Revenue") or 0 - rev_yoy = ((rev0 - rev1) / rev1 * 100) if rev1 and rev1 != 0 else 0 - return { - "theta": ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"], - "r": _radar_norm(roe, current_ratio, asset_turnover, equity_mult, rev_yoy), - "labels": ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"], - } - - -@st.cache_data(ttl=300) -def get_radar_metrics_normalized(ticker: str) -> dict: - """ROE, Current Ratio, Asset Turnover, Equity Mult, Revenue YoY. Normalized to 0-100 for radar. Returns {theta: [...], r: [...], labels: [...]} or empty.""" - if not ticker: - return {} - q = get_dupont_altman_redflags_yoy(ticker) - if not q: - return {} - dupont_df = q.get("dupont") - if dupont_df is None or dupont_df.empty or len(dupont_df) < 2: - return {} - row0 = dupont_df.iloc[0] - row1 = dupont_df.iloc[1] - roe = row0.get("ROE %") or 0 - cr = row0.get("Current Ratio") or 0 - at = row0.get("Asset Turnover") or 0 - em = row0.get("Equity Mult.") or 0 - rev0 = dupont_df["Revenue"].iloc[0] if "Revenue" in dupont_df.columns else None - rev1 = dupont_df["Revenue"].iloc[1] if "Revenue" in dupont_df.columns else None - rev_yoy = ((rev0 - rev1) / rev1 * 100) if (rev0 and rev1 and rev1 != 0) else 0 - def norm_roe(x): - if x is None: return 50 - return min(100, max(0, (x + 10) / 40 * 100)) - def norm_cr(x): - if x is None: return 50 - return min(100, max(0, x / 3 * 100)) - def norm_at(x): - if x is None: return 50 - return min(100, max(0, x * 50)) - def norm_em(x): - if x is None: return 50 - return min(100, max(0, (x - 0.5) / 2.5 * 100)) - def norm_yoy(x): - if x is None: return 50 - return min(100, max(0, (x + 20) / 50 * 100)) - return { - "theta": ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"], - "r": [norm_roe(roe), norm_cr(cr), norm_at(at), norm_em(em), norm_yoy(rev_yoy)], - "labels": ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"], - } - - -def _build_radar_figure(ticker: str) -> "go.Figure": - """Plotly line_polar radar chart; fill with translucent color.""" - if go is None: - return None - data = get_radar_metrics_normalized(ticker) - if not data or not data.get("r"): - return None - theta = data["theta"] + [data["theta"][0]] - r = data["r"] + [data["r"][0]] - fig = go.Figure(data=go.Scatterpolar(r=r, theta=theta, fill="toself", fillcolor="rgba(30, 120, 200, 0.4)", line=dict(color="rgb(30,120,200)", width=2))) - fig.update_layout(polar=dict(radialaxis=dict(visible=True, range=[0, 100]), angularaxis=dict(tickfont=dict(size=11))), title="Financial Health Radar", height=400, showlegend=False) - return fig - - -def _build_radar_figure_from_metrics(metrics: dict) -> "go.Figure": - """Build radar chart from precomputed metrics dict (theta, r, labels). Used for SEC Item 8 AI path.""" - if go is None or not metrics or not metrics.get("r"): - return None - theta = metrics["theta"] + [metrics["theta"][0]] - r = metrics["r"] + [metrics["r"][0]] - fig = go.Figure(data=go.Scatterpolar(r=r, theta=theta, fill="toself", fillcolor="rgba(30, 120, 200, 0.4)", line=dict(color="rgb(30,120,200)", width=2))) - fig.update_layout(polar=dict(radialaxis=dict(visible=True, range=[0, 100]), angularaxis=dict(tickfont=dict(size=11))), title="Financial Health Radar (from 10-K Item 8)", height=400, showlegend=False) - return fig - - -def _radar_norm(roe_pct, current_ratio, asset_turnover, equity_mult, rev_yoy_pct): - """Normalize 5 raw metrics to 0–100 for radar (same logic as get_radar_metrics_normalized).""" - def n_roe(x): return min(100, max(0, (x + 10) / 40 * 100)) if x is not None else 50 - def n_cr(x): return min(100, max(0, x / 3 * 100)) if x is not None else 50 - def n_at(x): return min(100, max(0, x * 50)) if x is not None else 50 - def n_em(x): return min(100, max(0, (x - 0.5) / 2.5 * 100)) if x is not None else 50 - def n_yoy(x): return min(100, max(0, (x + 20) / 50 * 100)) if x is not None else 50 - return [n_roe(roe_pct), n_cr(current_ratio), n_at(asset_turnover), n_em(equity_mult), n_yoy(rev_yoy_pct)] - - -def _build_radar_from_manual(roe_pct, current_ratio, asset_turnover, equity_mult, rev_yoy_pct) -> "go.Figure": - """Build radar chart from 5 manually entered ratios (fallback when ticker data missing).""" - if go is None: - return None - theta = ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"] - r = _radar_norm(roe_pct, current_ratio, asset_turnover, equity_mult, rev_yoy_pct) - theta_closed = theta + [theta[0]] - r_closed = r + [r[0]] - fig = go.Figure(data=go.Scatterpolar(r=r_closed, theta=theta_closed, fill="toself", fillcolor="rgba(30, 120, 200, 0.4)", line=dict(color="rgb(30,120,200)", width=2))) - fig.update_layout(polar=dict(radialaxis=dict(visible=True, range=[0, 100]), angularaxis=dict(tickfont=dict(size=11))), title="Financial Health Radar (Manual)", height=400, showlegend=False) - return fig - - -@st.cache_data(ttl=300) -def get_piotroski_fscore(ticker: str) -> dict: - """Piotroski F-Score (0-9) from last 2 periods. Uses yahooquery then yfinance with TTM fallback. Returns score + criteria + used_ttm.""" - out = {"score": 0, "criteria": [], "used_ttm": False} - fin, bal, cf = _get_annual_financials_balance_cashflow(ticker) - if fin is None or fin.empty or bal is None or bal.empty: - return out - if cf is None or cf.empty: - cf = pd.DataFrame() - try: - ncol = min(2, len(fin.columns)) - rev = _get_row_series(fin, "Total Revenue", "Revenue") - ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders") - gross = _get_row_series(fin, "Gross Profit") - ta = _get_row_series(bal, "Total Assets") - lt_debt = _get_row_series(bal, "Long Term Debt") - ca = _get_row_series(bal, "Current Assets") - cl = _get_row_series(bal, "Current Liabilities") - ocf = _get_row_series(cf, "Operating Cash Flow", "Cash From Operating Activities") if not cf.empty else None - shares = _get_row_series(bal, "Share Issued") or _get_row_series(bal, "Ordinary Shares Number") - if shares is None and yf: - t = yf.Ticker(ticker.upper()) - info = getattr(t, "info", None) or {} - sh_info = info.get("sharesOutstanding") or info.get("Shares Outstanding") - if sh_info is not None: - try: - sh_float = float(sh_info) - shares = pd.Series([sh_float] * ncol, index=fin.columns[:ncol]) - except (TypeError, ValueError): - pass - def v0(s): - if s is None or len(s) == 0: - return None - x = _safe_float(s.iloc[0]) - return x if (x is not None and x == x and not (isinstance(x, float) and pd.isna(x))) else None - def v1(s): - if s is None or len(s) < 2: - return None - x = _safe_float(s.iloc[1]) - return x if (x is not None and x == x and not (isinstance(x, float) and pd.isna(x))) else None - ni0, ni1 = v0(ni), v1(ni) - ocf0 = v0(ocf) if ocf is not None else None - ta0, ta1 = v0(ta), v1(ta) - roa0 = (ni0 / ta0 * 100) if (ni0 is not None and ta0 is not None and ta0 != 0) else None - roa1 = (ni1 / ta1 * 100) if (ni1 is not None and ta1 is not None and ta1 != 0) else None - c1 = (ni0 is not None and ni0 > 0) - c2 = (ocf0 is not None and ocf0 > 0) - c3 = (roa0 is not None and roa1 is not None and roa0 > roa1) - c4 = (ocf0 is not None and ni0 is not None and ocf0 > ni0) - lt0 = v0(lt_debt) or 0 - lt1 = v1(lt_debt) or 0 - c5 = (ta0 is not None and ta0 != 0 and ta1 is not None and ta1 != 0 and (lt0 / ta0) < (lt1 / ta1)) - cl0, cl1 = v0(cl), v1(cl) - ca0, ca1 = v0(ca), v1(ca) - cr0 = (ca0 / cl0) if (ca0 is not None and cl0 is not None and cl0 != 0) else None - cr1 = (ca1 / cl1) if (ca1 is not None and cl1 is not None and cl1 != 0) else None - c6 = (cr0 is not None and cr1 is not None and cr0 > cr1) - sh0, sh1 = v0(shares), v1(shares) - c7 = (sh0 is not None and sh1 is not None and sh0 <= sh1) if (sh0 is not None and sh1 is not None) else True - rev0, rev1 = v0(rev), v1(rev) - gm0 = (v0(gross) / rev0 * 100) if (gross is not None and rev0 is not None and rev0 != 0) else None - gm1 = (v1(gross) / rev1 * 100) if (gross is not None and rev1 is not None and rev1 != 0) else None - c8 = (gm0 is not None and gm1 is not None and gm0 > gm1) - at0 = (rev0 / ta0) if (rev0 is not None and ta0 is not None and ta0 != 0) else None - at1 = (rev1 / ta1) if (rev1 is not None and ta1 is not None and ta1 != 0) else None - c9 = (at0 is not None and at1 is not None and at0 > at1) - criteria = [ - ("Net Income > 0 (profitability)", c1), - ("Operating Cash Flow > 0 (cash generative)", c2), - ("ROA increased vs prior period (improving returns)", c3), - ("OCF > Net Income (earnings quality, less accruals)", c4), - ("Leverage decreased: LT Debt/Assets lower (less debt)", c5), - ("Current Ratio improved (better liquidity)", c6), - ("No dilution: shares unchanged or lower (no equity raise)", c7), - ("Gross Margin improved (pricing power)", c8), - ("Asset Turnover improved (efficiency)", c9), - ] - score = sum(1 for _, p in criteria if p) - out["score"] = score - out["criteria"] = criteria - out["used_ttm"] = bool(fin is not None and hasattr(fin, "columns") and len(fin.columns) > 0 and any(str(c).startswith("TTM") for c in fin.columns)) - return out - except Exception: - out["used_ttm"] = False - return out - - -@st.cache_data(ttl=300) -def get_sector_specific_metrics(ticker: str, sector: str) -> dict: - """Technology: Rule of 40, R&D % revenue. Retail/Consumer: Inventory Turnover, Operating Margin. Financials: ROE, ROA.""" - if not yf: - return {} - try: - t = yf.Ticker(ticker.upper()) - info = t.info or {} - fin = t.financials - bal = t.balance_sheet - if fin is None or fin.empty: - fin = getattr(t, "quarterly_financials", None) - if fin is not None and not fin.empty: - fin = fin.iloc[:, :4].sum(axis=1).to_frame() - if bal is None or bal.empty: - bal = getattr(t, "quarterly_balance_sheet", None) - out = {} - sector_lower = (sector or "").lower() - if "technology" in sector_lower or "software" in sector_lower or "tech" in sector_lower: - rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue") - ocf = _get_row_series(t.cashflow or getattr(t, "quarterly_cashflow", None), "Operating Cash Flow", "Cash From Operating Activities") - capx = _get_row_series(t.cashflow or getattr(t, "quarterly_cashflow", None), "Capital Expenditure", "Capital Expenditures") - rd = _get_row_series(fin, "Research And Development", "Research And Development Expense") - if rev is not None and len(rev) > 0: - r0 = _safe_float(rev.iloc[0]) - if ocf is not None and len(ocf) > 0 and capx is not None and len(capx) > 0: - fcf = _safe_float(ocf.iloc[0]) - _safe_float(capx.iloc[0]) - out["FCF Margin %"] = round(fcf / r0 * 100, 2) if r0 and fcf is not None else None - if rd is not None and len(rd) > 0: - out["R&D % of Revenue"] = round(_safe_float(rd.iloc[0]) / r0 * 100, 2) if r0 else None - rev_growth = None - if rev is not None and len(rev) >= 2: - cur, prev = _safe_float(rev.iloc[0]), _safe_float(rev.iloc[1]) - if prev and prev != 0: - rev_growth = (cur - prev) / prev * 100 - if rev_growth is not None and "FCF Margin %" in out and out["FCF Margin %"] is not None: - out["Rule of 40 (Rev Growth + FCF Margin)"] = round(rev_growth + out["FCF Margin %"], 1) - if "consumer" in sector_lower or "retail" in sector_lower or "cyclical" in sector_lower: - inv = _get_row_series(bal, "Inventory", "Total Inventory") - cogs = _get_row_series(fin, "Cost Of Revenue", "Cost Of Goods Sold", "Cost of Goods Sold") - rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue") - op_inc = _get_row_series(fin, "Operating Income", "EBIT") - if inv is not None and len(inv) > 0 and cogs is not None and len(cogs) > 0: - inv0 = _safe_float(inv.iloc[0]) - cogs0 = _safe_float(cogs.iloc[0]) - out["Inventory Turnover"] = round(cogs0 / inv0, 2) if inv0 else None - if rev is not None and len(rev) > 0 and op_inc is not None and len(op_inc) > 0: - r0 = _safe_float(rev.iloc[0]) - op0 = _safe_float(op_inc.iloc[0]) - out["Operating Margin %"] = round(op0 / r0 * 100, 2) if r0 else None - if "financial" in sector_lower or "bank" in sector_lower or "insurance" in sector_lower: - ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders") - te = _get_row_series(bal, "Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest") - ta = _get_row_series(bal, "Total Assets") - if ni is not None and te is not None and len(ni) > 0 and len(te) > 0: - te0 = _safe_float(te.iloc[0]) - ni0 = _safe_float(ni.iloc[0]) - out["ROE %"] = round(ni0 / te0 * 100, 2) if te0 else None - if ni is not None and ta is not None and len(ni) > 0 and len(ta) > 0: - ta0 = _safe_float(ta.iloc[0]) - ni0 = _safe_float(ni.iloc[0]) - out["ROA %"] = round(ni0 / ta0 * 100, 2) if ta0 else None - return out - except Exception: - return {} - - -# ---------- Streamlit UI ---------- -st.set_page_config(page_title="Financial Analysis Dashboard", layout="wide", initial_sidebar_state="expanded") - -# Professional styling -st.markdown(""" - -""", unsafe_allow_html=True) - -st.title("All-in-One Financial Analysis Dashboard") -st.caption("Hybrid: Gemini for qualitative (10-K MD&A & Risks); yahooquery + yfinance for quantitative (DCF, Comps). No API key needed for fundamentals.") - -with st.sidebar: - st.header("Settings") - _prefs = _load_prefs() - _default_key = _prefs.get("google_api_key") or os.environ.get("GOOGLE_API_KEY", "") - _default_email = _prefs.get("sec_email") or os.environ.get("SEC_EDGAR_EMAIL", "") - google_api_key = st.text_input( - "Google API Key (Gemini)", - type="password", - value=_default_key, - help="Required for Tab 1 (10-K insights).", - key="input_google_api_key", - ) - sec_email = st.text_input( - "SEC EDGAR Email", - value=_default_email, - help="Required for 10-K download.", - key="input_sec_email", - ) - remember_me = st.checkbox( - "Remember API key & email (save locally)", - value=bool(_prefs), - help="Store in .app_prefs.json in this project. Uncheck to clear and stop saving.", - key="remember_me", - ) - if remember_me and (google_api_key or sec_email): - _save_prefs(google_api_key, sec_email) - elif not remember_me and _PREFS_PATH.exists(): - try: - _PREFS_PATH.unlink() - except Exception: - pass - st.markdown("**🔍 Company search**") - search_query = st.text_input( - "Search Company Name (e.g., Apple, 삼성, Mitsubishi)", - value=st.session_state.get("company_search_input", ""), - key="company_search_input", - placeholder="e.g. Apple, 삼성, Mitsubishi", - ) - if st.button("Search Company", key="search_company_btn"): - query = (search_query or "").strip() - if not query: - st.warning("Enter a company name to search.") - elif yq_search is None: - st.warning("yahooquery is not installed; search is unavailable.") - else: - try: - raw_results = yq_search(query) - if not isinstance(raw_results, dict): - raw_results = {} - quotes = raw_results.get("quotes", []) or [] - skip_types = ("INDEX", "MUTUALFUND") - quotes = [ - q for q in quotes - if q.get("symbol") and q.get("shortname") - and (q.get("quoteType") or "EQUITY") not in skip_types - ] - if not quotes: - st.session_state["company_search_options"] = [] - st.session_state["company_search_symbols"] = [] - st.warning("No valid equities found. Try typing the English name (e.g., 'Samsung' instead of '삼성').") - else: - options = [] - symbols = [] - for q in quotes[:50]: - sym = (q.get("symbol") or "").strip() - options.append(f"[{q.get('exchange', 'N/A')}] {q.get('symbol')} - {q.get('shortname', 'Unknown')}") - symbols.append(sym) - st.session_state["company_search_options"] = options - st.session_state["company_search_symbols"] = symbols - st.session_state["ticker"] = symbols[0] - st.success(f"Found {len(options)} result(s). Select below.") - except Exception: - st.warning("No valid equities found. Try typing the English name (e.g., 'Samsung' instead of '삼성').") - st.session_state["company_search_options"] = [] - st.session_state["company_search_symbols"] = [] - - search_options = st.session_state.get("company_search_options") or [] - search_symbols = st.session_state.get("company_search_symbols") or [] - placeholder = "— Click the search button above —" - options_for_select = [placeholder] if not search_options else search_options - current_ticker = st.session_state.get("ticker", "NVDA") - default_idx = 0 - if search_symbols and current_ticker: - for i, sym in enumerate(search_symbols): - if sym == current_ticker: - default_idx = i - break - selected_option = st.selectbox( - "Select company (ticker - name)", - options=options_for_select, - index=0 if not search_options else min(default_idx, len(search_options) - 1), - key="company_select", - ) - if search_options and selected_option and selected_option != placeholder and " - " in selected_option: - first_part = selected_option.split(" - ", 1)[0].strip() - sym = first_part.split("]", 1)[-1].strip() if "]" in first_part else first_part - st.session_state["ticker"] = sym - ticker = st.session_state.get("ticker") or (search_symbols[0] if search_symbols else "NVDA") - st.session_state["google_api_key"] = google_api_key - st.session_state["sec_email"] = sec_email - st.session_state["ticker"] = ticker - st.session_state["market"] = infer_market_from_ticker(ticker) - st.caption("Search by name (any language), then select. Ticker suffix is set automatically.") - -tab1, tab2, tab3 = st.tabs(["10-K & MD&A Insights", "3-Scenario DCF Valuation", "Industry Analysis & Comps"]) - -# ----- Tab 1: Qualitative (MD&A) + Quantitative (DuPont, Altman Z, Red Flags, YoY) ----- + st.markdown(f'
{_cells}
', unsafe_allow_html=True) + +# ---------- Sidebar ---------- +ticker = render_sidebar() + +# ---------- Tabs ---------- +tab1, tab2, tab3, tab4, tab5, tab6, tab7 = st.tabs([ + "\U0001f4ca 10-K & MD&A Insights", + "\U0001f4b0 DCF Valuation", + "\U0001f3ed Industry Comps", + "\U0001f4f0 News Feed", + "\U0001f30d Markets & FX", + "\u20bf Crypto", + "\U0001f6e1 Technical & Risk", +]) + +# ----- Tab 1: 10-K & MD&A Insights ----- with tab1: market = st.session_state.get("market") or MARKET_OPTIONS[0] quant_ticker = get_global_ticker(ticker, market) if ticker else "" @@ -2315,500 +82,37 @@ with tab1: if ticker: si = get_sector_industry(quant_ticker) sector, industry = si.get("sector", "N/A"), si.get("industry", "N/A") - st.caption(f"Sector: **{sector}** · Industry: **{industry}**" + (f" · Ticker: **{quant_ticker}**" if quant_ticker != ticker else "")) + st.caption( + f"Sector: **{sector}** · Industry: **{industry}**" + + (f" · Ticker: **{quant_ticker}**" if quant_ticker != ticker else "") + ) if ticker: google_api_key = (st.session_state.get("google_api_key") or "").strip() sec_email = (st.session_state.get("sec_email") or "").strip() - ai_data = {} - if market and "US" in market and google_api_key and sec_email: - with st.spinner("SEC 10-K 원본에서 재무제표 데이터를 해독하여 그래프를 생성 중입니다... (약 30~60초 소요)"): - sections, _ = get_10k_sections(ticker, sec_email) - item8 = (sections or {}).get("item8") or "" - if item8.strip(): - ai_data = get_sec_financials_llm(google_api_key, item8, ticker) - q = get_dupont_altman_redflags_yoy(quant_ticker) - dupont_df = (q or {}).get("dupont") if q else None - if q or ai_data: - st.markdown("---") - st.markdown("#### 📊 Financial Health (Tables & Charts)") - c1, c2 = st.columns(2) - with c1: - if ai_data and ai_data.get("current_yr"): - sankey_data = sankey_data_from_ai(ai_data) - else: - sankey_data = get_income_statement_sankey_data(quant_ticker) - if sankey_data.get("revenue", 0) > 0: - fig_sankey = _build_sankey_figure(sankey_data) - if fig_sankey is not None: - st.plotly_chart(fig_sankey, use_container_width=True) - else: - st.caption("Income Statement flow: data not available.") - with c2: - if ai_data and ai_data.get("current_yr"): - radar_metrics = radar_metrics_from_ai(ai_data) - fig_radar = _build_radar_figure_from_metrics(radar_metrics) if radar_metrics else None - else: - fig_radar = _build_radar_figure(quant_ticker) - if fig_radar is not None: - st.plotly_chart(fig_radar, use_container_width=True) - else: - st.caption("Financial radar: need 2+ years of data.") - with st.expander("Manual Data Entry (Radar Chart Fallback)", expanded=False): - st.caption("Enter 5 key ratios to plot a custom radar. ROE %, Current Ratio, Asset Turnover, Equity Mult., Revenue YoY %.") - roe_man = st.number_input("ROE %", value=15.0, min_value=-50.0, max_value=100.0, step=1.0, key="radar_roe") - cr_man = st.number_input("Current Ratio", value=1.5, min_value=0.0, max_value=10.0, step=0.1, key="radar_cr") - at_man = st.number_input("Asset Turnover", value=0.8, min_value=0.0, max_value=5.0, step=0.1, key="radar_at") - em_man = st.number_input("Equity Mult.", value=2.0, min_value=0.5, max_value=10.0, step=0.1, key="radar_em") - yoy_man = st.number_input("Revenue YoY %", value=10.0, min_value=-50.0, max_value=200.0, step=1.0, key="radar_yoy") - if st.button("Plot Radar", key="radar_plot_btn"): - fig_man = _build_radar_from_manual(roe_man, cr_man, at_man, em_man, yoy_man) - if fig_man is not None: - st.session_state["radar_manual_fig"] = fig_man - if st.session_state.get("radar_manual_fig") is not None: - st.plotly_chart(st.session_state["radar_manual_fig"], use_container_width=True) - with st.expander("Debug: Raw YahooQuery Data", expanded=False): - if YQTicker and quant_ticker: - try: - yq_ticker = YQTicker(quant_ticker.upper()) - inc_raw = yq_ticker.income_statement(trailing=False) - bal_raw = yq_ticker.balance_sheet(trailing=False) - if inc_raw is not None and not inc_raw.empty: - st.caption("Income statement (last 2 periods) — check column names for mapping.") - st.dataframe(inc_raw.tail(2), use_container_width=True, hide_index=True) - else: - st.caption("Income statement: no data.") - if bal_raw is not None and not bal_raw.empty: - st.caption("Balance sheet (last 2 periods) — check column names for mapping.") - st.dataframe(bal_raw.tail(2), use_container_width=True, hide_index=True) - else: - st.caption("Balance sheet: no data.") - except Exception as e: - st.error(f"YahooQuery debug failed: {e}") - else: - st.caption("YahooQuery not available or no ticker selected.") - if ai_data and ai_data.get("current_yr"): - piot = piotroski_from_ai(ai_data) - else: - piot = get_piotroski_fscore(quant_ticker) - st.markdown("**Piotroski F-Score (9-point checklist)**") - score = piot.get("score", 0) - legend = "**Score 8–9: Excellent** · 4–7: Average · 0–3: High Risk" - st.metric("F-Score", f"{score} / 9", legend) - if ai_data and ai_data.get("current_yr"): - st.caption("*(from SEC 10-K Item 8)*") - elif piot.get("used_ttm"): - st.caption("*(Estimated via TTM Data)*") - st.caption("✅ = Good (passes criterion). ❌ = Fails criterion.") - criteria = piot.get("criteria", []) - if criteria: - cols = st.columns(3) - for i, (label, passed) in enumerate(criteria): - with cols[i % 3]: - st.caption(("✅ " if passed else "❌ ") + label) - az = (q or {}).get("altman_z") - if az is not None: - st.caption(f"**Altman Z-Score:** {az} (Safe > 2.99 · Grey 1.81–2.99 · Distress < 1.81)") - red_flags = (q or {}).get("red_flags") or [] - if red_flags: - for rf in red_flags: - val = rf.get("value") - val_str = "N/A" if (val is None or (isinstance(val, float) and (pd.isna(val) or val != val))) else val - st.warning(f"**{rf.get('metric')}:** {val_str} (threshold: {rf.get('threshold')})") - elif dupont_df is not None and not dupont_df.empty: - st.success("No red flags (Current Ratio ≥ 1.0, Interest Coverage ≥ 1.5).") - sector_metrics = get_sector_specific_metrics(quant_ticker, sector) if quant_ticker else {} - if sector_metrics: - st.markdown("**Sector-specific metrics**") - cols = st.columns(min(len(sector_metrics), 4)) - for i, (k, v) in enumerate(sector_metrics.items()): - with cols[i % len(cols)]: - disp = f"{v}" if v is not None else "N/A" - st.metric(k, disp, None) - def _style_change_column(df: pd.DataFrame): - """Green for improvement (+), red for decline (-) in Change column.""" - change_col = "Change (%)" if "Change (%)" in df.columns else "Change" - if change_col not in df.columns or df.empty: - return df.style - def _cell_style(v): - if v is None or (isinstance(v, float) and pd.isna(v)): - return "" - s = str(v).strip() - if s == "—": - return "" - if s.startswith("+") or "↑" in s: - return "background-color: #d4edda; color: #155724" - if s.startswith("-") or "↓" in s: - return "background-color: #f8d7da; color: #721c24" - return "" - return df.style.apply(lambda col: [_cell_style(v) for v in col], subset=[change_col]) - yoy_list = (q or {}).get("yoy") or [] - if yoy_list: - st.markdown("**YoY ratio changes**") - rows_yoy = [] - for item in yoy_list: - cur = item.get("Latest") - chg_pp = item.get("YoY (pp)") - chg_pct = item.get("YoY %") - if chg_pp is not None: - chg_str = f"{chg_pp:+.1f}%" - elif chg_pct is not None: - chg_str = f"{chg_pct:+.1f}%" - else: - chg_str = "—" - status = "↑" if (chg_pp is not None and chg_pp > 0) or (chg_pct is not None and chg_pct > 0) else ("↓" if (chg_pp is not None and chg_pp < 0) or (chg_pct is not None and chg_pct < 0) else "—") - rows_yoy.append({"Metric": item.get("Ratio"), "Current Value": cur, "Change (%)": chg_str, "Status": status}) - if rows_yoy: - df_yoy = pd.DataFrame(rows_yoy) - st.dataframe(_style_change_column(df_yoy), use_container_width=True, hide_index=True) - st.markdown("**Quarter ratio changes**") - qmom = get_quarterly_momentum(quant_ticker) - qoq_rows = get_quarterly_ratio_changes(quant_ticker) - qoq_r, qoq_n = qmom.get("qoq_revenue_pct"), qmom.get("qoq_ni_pct") - build = [] - if qoq_r is not None: - build.append({"Metric": "Revenue", "Current Value": "—", "Change (%)": f"{qoq_r:+.1f}%", "Status": "↑" if qoq_r > 0 else "↓"}) - if qoq_n is not None: - build.append({"Metric": "Net Income", "Current Value": "—", "Change (%)": f"{qoq_n:+.1f}%", "Status": "↑" if qoq_n > 0 else "↓"}) - for r in qoq_rows: - r_copy = dict(r) - if "Change" in r_copy and "Change (%)" not in r_copy: - r_copy["Change (%)"] = r_copy.pop("Change", "—") - if "Trend" in r_copy: - r_copy["Status"] = r_copy.pop("Trend", "—") - build.append(r_copy) - if build: - df_q = pd.DataFrame(build) - if "Change" in df_q.columns and "Change (%)" not in df_q.columns: - df_q = df_q.rename(columns={"Change": "Change (%)"}) - if "Trend" in df_q.columns: - df_q = df_q.rename(columns={"Trend": "Status"}) - st.dataframe(_style_change_column(df_q), use_container_width=True, hide_index=True) - elif not qmom.get("df") or qmom["df"].empty: - st.caption("Quarterly data not available for this ticker.") - else: - st.info("Quantitative data not available for this ticker.") - st.markdown("---") - st.markdown("#### 🔍 Deep-Dive Analysis (AI)") - st.caption("10-K sections are cached in **data/**; repeat runs use cache for instant AI analysis. First run may take 20–60 s to fetch 10-K; Gemini then streams in ~5–10 s.") - if not ticker: - st.caption("Enter a ticker in the sidebar to enable analysis.") - else: - api_ok = bool(st.session_state.get("google_api_key")) - email_ok = bool(st.session_state.get("sec_email")) - err_msg = [] - if not api_ok: - err_msg.append("Google API Key") - if not email_ok: - err_msg.append("SEC EDGAR Email") - if err_msg: - st.caption(f"Set **{' and '.join(err_msg)}** in the sidebar to run analysis.") - col_a, col_b = st.columns(2) - is_us = market and "US" in market - is_korea = market and ("Korea" in market or "KOSPI" in market or "KOSDAQ" in market) - is_japan_uk = market and ("Japan" in market or "Nikkei" in market or "UK" in market or "LSE" in market) - # --- Button A: Management Strategy --- - with col_a: - if st.button("Analyze Management Strategy (MD&A)", key="run_mda_strategy"): - if is_korea: - st.warning("DART API integration for Korean MD&A is currently under construction. Please check back in Phase 2.") - elif is_japan_uk: - st.warning("EDINET/LSE document parsing is currently under development.") - elif not api_ok or not email_ok: - st.error("Set API Key and SEC Email in the sidebar.") - else: - try: - with st.status("Loading 10-K (cache or download)...", expanded=True) as status: - sections, _ = get_10k_sections(ticker, st.session_state["sec_email"]) - si = get_sector_industry(quant_ticker) - status.update(label="10-K loaded. Calling Gemini…", state="running") + render_tab1_quantitative(ticker, quant_ticker, market, sector, industry, google_api_key, sec_email) + render_tab1_ai_analysis(ticker, quant_ticker, market) + render_tab1_filings(ticker, market) - # Stream OUTSIDE the status box so user sees text as it arrives - st.markdown("### Management Strategy (Item 7)") - st.caption("Streaming from Gemini (first words in ~5–10 sec, then flows in real time).") - stream_gen = get_gemini_item7_strategy_stream( - st.session_state["google_api_key"], - sections.get("item7") or "", - ticker, - si.get("sector") or "N/A", - si.get("industry") or "N/A", - ) - # write_stream returns the full concatenated string after it finishes streaming - full_response = st.write_stream(stream_gen) - - st.session_state["mda_strategy_result"] = full_response - st.session_state["mda_strategy_ticker"] = ticker - st.session_state["mda_strategy_error"] = None - except Exception as e: - st.session_state["mda_strategy_error"] = str(e) - st.error(f"Strategy analysis failed: {str(e)}") - # --- Button B: Risk Factors & Forensic --- - with col_b: - if st.button("Analyze Risk Factors (Item 1A)", key="run_mda_risk"): - if is_korea: - st.warning("DART API integration for Korean MD&A is currently under construction. Please check back in Phase 2.") - elif is_japan_uk: - st.warning("EDINET/LSE document parsing is currently under development.") - elif not api_ok or not email_ok: - st.error("Set API Key and SEC Email in the sidebar.") - else: - try: - with st.status("Loading 10-K (cache or download)...", expanded=True) as status: - sections, _ = get_10k_sections(ticker, st.session_state["sec_email"]) - status.update(label="10-K loaded. Running forensic audit…", state="running") - - # Run forensic silently IN THE BACKGROUND first - forensic = _gemini_forensic_audit( - st.session_state["google_api_key"], - sections.get("item3") or "", - sections.get("item9a") or "", - ticker, - ) - status.update(label="Done.", state="complete") - # Stream the risk factors OUTSIDE the status box - st.markdown("### Risk Factors (Item 1A)") - st.caption("Streaming from Gemini (first words in ~5–10 sec, then flows in real time).") - stream_gen = get_gemini_item1a_risks_stream( - st.session_state["google_api_key"], - sections.get("item1a") or "", - ticker, - ) - risk_response = st.write_stream(stream_gen) - - # Combine both for the final result - final_out = risk_response - if forensic and forensic.strip(): - st.markdown("### Forensic Audit (Item 3 & 9A)") - st.markdown(forensic.strip()) - final_out += f"\n\n---\n\n### Forensic Audit (Item 3 & 9A)\n\n{forensic.strip()}" - - st.session_state["mda_risk_result"] = final_out - st.session_state["mda_risk_ticker"] = ticker - st.session_state["mda_risk_error"] = None - except Exception as e: - st.session_state["mda_risk_error"] = str(e) - st.error(f"Risk analysis failed: {str(e)}") - # --- Display Saved Results if User Switches Tabs --- - st.markdown("---") - if st.session_state.get("mda_strategy_ticker") == ticker: - if st.session_state.get("mda_strategy_error"): - st.error("Strategy Error: " + st.session_state["mda_strategy_error"]) - elif st.session_state.get("mda_strategy_result"): - with st.expander("View Previous Strategy Analysis", expanded=True): - st.markdown(st.session_state["mda_strategy_result"]) - if st.session_state.get("mda_risk_ticker") == ticker: - if st.session_state.get("mda_risk_error"): - st.error("Risk Error: " + st.session_state["mda_risk_error"]) - elif st.session_state.get("mda_risk_result"): - with st.expander("View Previous Risk & Forensic Analysis", expanded=True): - st.markdown(st.session_state["mda_risk_result"]) - -# ----- Tab 2: 5-Year Trend + 3-Scenario DCF ----- +# ----- Tab 2: DCF Valuation ----- with tab2: - market_t2 = st.session_state.get("market") or MARKET_OPTIONS[0] - quant_ticker_t2 = get_global_ticker(ticker, market_t2) if ticker else "" - st.subheader("5-Year Financial Trend & DCF Valuation") - if ticker: - si_t2 = get_sector_industry(quant_ticker_t2) - sector_t2 = (si_t2.get("sector") or "").lower() - is_financial = "financial" in sector_t2 or "bank" in sector_t2 or "insurance" in sector_t2 - else: - is_financial = False - df_trend = get_5yr_financial_trend(quant_ticker_t2) if quant_ticker_t2 else pd.DataFrame() - if not df_trend.empty and len(df_trend) >= 1: - st.markdown("#### Key metrics (YoY % change)") - latest = df_trend.iloc[0] - prev = df_trend.iloc[1] if len(df_trend) >= 2 else None - def _yoy_pct(cur, prev_val): - if prev_val is None or cur is None or prev_val == 0: - return None - return (cur - prev_val) / abs(prev_val) * 100 - rev_yoy = _yoy_pct(latest.get("Revenue"), prev.get("Revenue") if prev is not None else None) - ni_yoy = _yoy_pct(latest.get("Net Income"), prev.get("Net Income") if prev is not None else None) - om_prev = prev.get("Operating Margin %") if prev is not None else None - om_cur = latest.get("Operating Margin %") - om_yoy = (om_cur - om_prev) if (om_cur is not None and om_prev is not None) else None - fcf_yoy = _yoy_pct(latest.get("FCF"), prev.get("FCF") if prev is not None else None) - m1, m2, m3, m4 = st.columns(4) - rev_val = latest.get("Revenue") - m1.metric("Revenue (latest yr)", f"${rev_val/1e9:.2f}B" if rev_val and rev_val >= 1e9 else (f"${rev_val/1e6:.0f}M" if rev_val else "—"), f"{rev_yoy:+.1f}% YoY" if rev_yoy is not None else None) - ni_val = latest.get("Net Income") - m2.metric("Net Income", f"${ni_val/1e9:.2f}B" if ni_val and abs(ni_val) >= 1e9 else (f"${ni_val/1e6:.0f}M" if ni_val is not None else "—"), f"{ni_yoy:+.1f}% YoY" if ni_yoy is not None else None) - om_val = latest.get("Operating Margin %") - m3.metric("Operating Margin %", f"{om_val:.1f}%" if om_val is not None else "—", f"{om_yoy:+.1f}pp YoY" if om_yoy is not None else None) - fcf_val = latest.get("FCF") - m4.metric("FCF", f"${fcf_val/1e9:.2f}B" if fcf_val and abs(fcf_val) >= 1e9 else (f"${fcf_val/1e6:.0f}M" if fcf_val is not None else "—"), f"{fcf_yoy:+.1f}% YoY" if fcf_yoy is not None else None) - st.caption("FCF = Operating Cash Flow − Capital Expenditure." + (" For Financials, FCF/EBITDA are less relevant; see ROE/ROA in Tab 1 sector-specific metrics." if is_financial else "")) - if len(df_trend) >= 2 and px is not None: - st.markdown("#### 5-year trend: Revenue & FCF") - df_plot = df_trend.copy() - df_plot["Revenue_M"] = (df_plot["Revenue"] / 1e6).round(1) - df_plot["FCF_M"] = (df_plot["FCF"] / 1e6).round(1) - fig = px.line(df_plot, x="Year", y=["Revenue_M", "FCF_M"], title="Revenue & Free Cash Flow ($M)") - fig.update_layout(yaxis_title="$M", legend_title="", hovermode="x unified") - fig.update_traces(line=dict(width=2)) - st.plotly_chart(fig, use_container_width=True) - elif ticker: - st.caption("5-year trend not available for this ticker. DCF section below uses latest FCF from yfinance.") - st.markdown("---") - st.markdown("#### DCF valuation (Excel-style): inputs & 3-scenario output") - dcf_inputs = get_dcf_inputs(quant_ticker_t2) if quant_ticker_t2 else {"fcf": None, "total_debt": 0.0, "cash": 0.0, "shares": None} - fcf_fetched = dcf_inputs.get("fcf") - total_debt = float(dcf_inputs.get("total_debt") or 0.0) - cash = float(dcf_inputs.get("cash") or 0.0) - shares_fetched = dcf_inputs.get("shares") - # Base FCF - if fcf_fetched is None or fcf_fetched <= 0: - fcf = st.number_input("Base FCF (manual — only if yfinance missing)", value=0.0, min_value=-1e12, step=1e8, format="%.0f", key="dcf_fcf_manual") - else: - fcf = float(fcf_fetched) - st.caption(f"Base FCF (OCF − CapEx): **${fcf/1e9:.2f}B**" if abs(fcf) >= 1e9 else f"Base FCF (OCF − CapEx): **${fcf/1e6:.0f}M**") - # Shares: auto-fetched (fast_info → info → balance); manual only as last resort - if shares_fetched is not None and shares_fetched > 0: - shares = float(shares_fetched) - st.caption(f"Shares Outstanding: **{_format_shares_display(shares)}** (real-time, auto-fetched)") - else: - shares = st.number_input("Shares Outstanding (manual — only if all API sources failed)", value=1e9, min_value=1.0, step=1e7, format="%.0f", key="dcf_shares_manual") - # Total Debt & Cash: manual only when both API sources completely failed - if total_debt == 0 and cash == 0: - c1, c2 = st.columns(2) - with c1: - total_debt = st.number_input("Total Debt (manual — only if all sources failed)", value=0.0, min_value=0.0, step=1e8, format="%.0f", key="dcf_debt_manual") - with c2: - cash = st.number_input("Cash & Equivalents (manual — only if all sources failed)", value=0.0, min_value=0.0, step=1e8, format="%.0f", key="dcf_cash_manual") - else: - st.caption(f"Total Debt: **${total_debt/1e9:.2f}B**" if total_debt >= 1e9 else f"Total Debt: **${total_debt/1e6:.0f}M**" if total_debt >= 1e6 else f"Total Debt: **${total_debt:,.0f}**") - st.caption(f"Cash & Equivalents: **${cash/1e9:.2f}B**" if cash >= 1e9 else f"Cash & Equivalents: **${cash/1e6:.0f}M**" if cash >= 1e6 else f"Cash & Equivalents: **${cash:,.0f}**") - dcf_defaults = get_dcf_smart_defaults(quant_ticker_t2) if quant_ticker_t2 else {"wacc_pct": 10.0, "term_growth_pct": 2.5, "fcf_growth_pct": 8.0} - st.markdown("**Assumptions (sliders)**") - st.caption("💡 Slider defaults are auto-generated based on the company's Beta (CAPM) and revenue growth estimates.") - col1, col2, col3 = st.columns(3) - with col1: - wacc = st.slider("WACC (Discount Rate) %", 4.0, 20.0, float(dcf_defaults["wacc_pct"]), 0.5, key="dcf_wacc") / 100.0 - with col2: - term_growth = st.slider("Terminal Growth Rate %", -2.0, 6.0, float(dcf_defaults["term_growth_pct"]), 0.25, key="dcf_term") / 100.0 - with col3: - base_growth = st.slider("Projected FCF Growth (Stage 1, Y1–5) %", -10.0, 30.0, float(dcf_defaults["fcf_growth_pct"]), 0.5, key="dcf_fcf_growth") / 100.0 - bull_growth = base_growth + 0.02 - bear_growth = base_growth - 0.02 - with st.expander("Reference: Analyst & Macro Assumptions", expanded=False): - left_col, right_col = st.columns(2) - with left_col: - st.markdown("**Analyst consensus (yfinance)**") - analyst = get_analyst_consensus(quant_ticker_t2) if quant_ticker_t2 else {} - st.markdown(f"- **Target mean price:** {analyst.get('targetMeanPrice', 'N/A')}") - st.markdown(f"- **Recommendation:** {analyst.get('recommendationKey', 'N/A')}") - st.markdown(f"- **Revenue growth est.:** {analyst.get('revenueGrowth', 'N/A')}") - st.markdown(f"- **Earnings growth est.:** {analyst.get('earningsGrowth', 'N/A')}") - with right_col: - st.markdown("**Aswath Damodaran — macro baseline**") - sector_name = get_sector_industry(ticker).get("sector", "N/A") if ticker else "N/A" - damodaran_wacc = _damodaran_wacc_for_sector(sector_name) if ticker else 8.0 - st.markdown(f"- **Sector WACC (ref.):** {damodaran_wacc:.1f}% (closest: {sector_name})") - st.markdown(f"- **US equity risk premium (ERP):** {DAMODARAN_ERP_PCT}%") - st.markdown(f"- **10Y risk-free rate:** {DAMODARAN_RF_PCT}%") - st.markdown("[Data & methodology (Damodaran)](https://pages.stern.nyu.edu/~adamodar/New_Home_Page/datafile/wacc.htm) so users can verify.") - res_base = excel_style_dcf(fcf, wacc, term_growth, base_growth, total_debt, cash, shares) - res_bull = excel_style_dcf(fcf, wacc, term_growth, bull_growth, total_debt, cash, shares) - res_bear = excel_style_dcf(fcf, wacc, term_growth, bear_growth, total_debt, cash, shares) - price_base = res_base.get("value_per_share") or 0.0 - price_bull = res_bull.get("value_per_share") or 0.0 - price_bear = res_bear.get("value_per_share") or 0.0 - current_price = None - if quant_ticker_t2 and yf: - try: - info = yf.Ticker(quant_ticker_t2.upper()).info or {} - current_price = info.get("currentPrice") or info.get("regularMarketPrice") or info.get("previousClose") - except Exception: - pass - st.markdown("**Intrinsic value vs current price**") - if current_price is not None and current_price > 0: - st.metric("Current price", f"${current_price:.2f}", None) - st.metric("Base case intrinsic value per share", f"${price_base:.2f}" if price_base else "N/A", f"vs current: {(price_base - current_price):.2f}" if (current_price and price_base) else None) - c1, c2, c3 = st.columns(3) - c1.metric("Bull (+2% FCF growth)", f"${price_bull:.2f}" if price_bull else "N/A", f"vs Base: +{(price_bull - price_base):.2f}" if (price_bull and price_base) else None) - c2.metric("Base", f"${price_base:.2f}" if price_base else "N/A", "—") - c3.metric("Bear (−2% FCF growth)", f"${price_bear:.2f}" if price_bear else "N/A", f"vs Base: {(price_bear - price_base):.2f}" if (price_bear and price_base) else None) - df_dcf = pd.DataFrame({ - "Scenario": ["Bull", "Base", "Bear"], - "FCF Growth %": [f"{bull_growth*100:.1f}", f"{base_growth*100:.1f}", f"{bear_growth*100:.1f}"], - "Intrinsic Value ($)": [round(price_bull, 2) if price_bull else "N/A", round(price_base, 2) if price_base else "N/A", round(price_bear, 2) if price_bear else "N/A"], - }) - st.dataframe(df_dcf, use_container_width=True, hide_index=True) + render_tab2(ticker) -# ----- Tab 3: Top-Down Sector Analysis (Industry Comps + AI Outlook) ----- +# ----- Tab 3: Industry Comps ----- with tab3: - st.subheader("Top-Down Sector Analysis") - st.markdown("Select an **industry** to load peer multiples (Forward P/E, EV/EBITDA, P/B). Green = lowest (undervalued), Red = highest. Optionally generate an **AI Industry Outlook**.") - sector_options = list(SECTORS.keys()) - selected_industry = st.selectbox("Select industry", sector_options, key="sector_select") - tickers_list = list(SECTORS.get(selected_industry, [])) - if not tickers_list: - st.warning("No tickers defined for this industry.") - else: - with st.spinner("Fetching market data..."): - df_comps = get_comps_data(tuple(tickers_list)) - if df_comps.empty: - st.warning("Could not fetch comps from yfinance. One or more tickers may have failed; try again later.") - else: - df_display = df_comps.copy() - for col in ["Forward P/E", "EV/EBITDA", "P/B"]: - if col not in df_display.columns: - continue - df_display[col] = df_display[col].apply( - lambda x: "N/A" if (x is None or (isinstance(x, float) and pd.isna(x))) else x - ) - try: - styled = df_comps.style - for col in ["Forward P/E", "EV/EBITDA", "P/B"]: - if col not in df_comps.columns: - continue - s = pd.to_numeric(df_comps[col], errors="coerce") - valid = s.dropna() - if len(valid) < 2: - continue - lo, hi = valid.min(), valid.max() - if lo == hi: - continue - def color_fn(v, lo_val=lo, hi_val=hi): - if pd.isna(v): - return "" - try: - x = float(v) - except (TypeError, ValueError): - return "" - if x <= lo_val: - return "background-color: rgba(0, 200, 83, 0.35); color: #0d5c2e" - if x >= hi_val: - return "background-color: rgba(255, 82, 82, 0.35); color: #b71c1c" - return "" - styled = styled.map(color_fn, subset=[col]) - styled = styled.format(subset=["Forward P/E", "EV/EBITDA", "P/B"], formatter=lambda x: "N/A" if (pd.isna(x) or x is None) else f"{x:.2f}") - st.dataframe(styled, use_container_width=True, hide_index=True) - except Exception: - st.dataframe(df_display, use_container_width=True, hide_index=True) - st.caption("Lowest multiple in each column = green (relatively undervalued); highest = red.") + render_tab3(ticker) - st.markdown("---") - st.markdown("#### AI Industry Outlook") - if st.button("Generate Industry Outlook", key="industry_outlook_btn"): - if not tickers_list: - st.error("Select an industry above first.") - elif not st.session_state.get("google_api_key"): - st.error("Enter your Google API Key in the sidebar.") - else: - try: - with st.spinner("Generating industry outlook with Gemini..."): - report = get_industry_outlook( - st.session_state["google_api_key"], - selected_industry, - tickers_list, - ) - st.success("Done.") - st.markdown(report) - except RuntimeError as e: - st.error(str(e)) - except Exception as e: - st.error("Failed to generate outlook. See details below.") - with st.expander("Error details"): - st.code(repr(e), language="text") +# ----- Tab 4: News Feed ----- +with tab4: + render_tab4(ticker) +# ----- Tab 5: Markets & FX ----- +with tab5: + render_tab5() + +# ----- Tab 6: Crypto ----- +with tab6: + render_tab6() + +# ----- Tab 7: Technical & Risk ----- +with tab7: + render_tab7(ticker) diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/constants.py b/config/constants.py new file mode 100644 index 0000000..2aee313 --- /dev/null +++ b/config/constants.py @@ -0,0 +1,115 @@ +""" +All global constants, company lists, sector maps, regex patterns, row maps, and Damodaran baselines. +""" + +# Company name → ticker for search/autocomplete (expand as needed) +COMPANY_LIST = [ + ("NVIDIA Corporation", "NVDA"), ("Apple Inc.", "AAPL"), ("Microsoft Corporation", "MSFT"), + ("Amazon.com Inc.", "AMZN"), ("Alphabet Inc.", "GOOGL"), ("Meta Platforms Inc.", "META"), + ("AMD", "AMD"), ("Intel Corporation", "INTC"), ("Qualcomm Inc.", "QCOM"), ("Tesla Inc.", "TSLA"), + ("Berkshire Hathaway", "BRK.B"), ("JPMorgan Chase", "JPM"), ("Visa Inc.", "V"), ("UnitedHealth", "UNH"), + ("Procter & Gamble", "PG"), ("Exxon Mobil", "XOM"), ("Johnson & Johnson", "JNJ"), ("Mastercard", "MA"), + ("Chevron", "CVX"), ("Home Depot", "HD"), ("Merck", "MRK"), ("AbbVie", "ABBV"), ("Costco", "COST"), + ("PepsiCo", "PEP"), ("Coca-Cola", "KO"), ("Pfizer", "PFE"), ("Walmart", "WMT"), ("Netflix", "NFLX"), + ("Adobe", "ADBE"), ("Salesforce", "CRM"), ("Comcast", "CMCSA"), ("Cisco", "CSCO"), ("Oracle", "ORCL"), + ("American Express", "AXP"), ("Bank of America", "BAC"), ("Wells Fargo", "WFC"), ("Verizon", "VZ"), + ("AT&T", "T"), ("Walt Disney", "DIS"), ("Nike", "NKE"), ("McDonald's", "MCD"), ("Starbucks", "SBUX"), + ("Goldman Sachs", "GS"), ("Morgan Stanley", "MS"), ("Target", "TGT"), ("Boeing", "BA"), ("IBM", "IBM"), +] +COMPANY_OPTIONS = [f"{t} - {n}" for n, t in COMPANY_LIST] +COMPANY_TICKER_MAP = {t: n for n, t in COMPANY_LIST} + +MARKET_OPTIONS = [ + "US (S&P/Dow/Nasdaq)", + "South Korea (KOSPI/KOSDAQ)", + "Japan (Nikkei)", + "UK (LSE)", +] + +# Top-down sector analysis: industry → top 5 S&P 500 / NASDAQ tickers +SECTORS = { + "Semiconductors & Hardware": ["NVDA", "AMD", "INTC", "TSM", "AVGO"], + "Software & Cloud": ["MSFT", "ADBE", "CRM", "PANW", "CRWD"], + "Consumer Retail": ["AMZN", "SBUX", "MCD", "WMT", "HD"], + "Financial Services": ["JPM", "BAC", "GS", "MS", "V"], + "Healthcare": ["LLY", "UNH", "JNJ", "ABBV", "MRK"], +} + +# Section patterns for 10-K items +ITEM1A_PATTERNS = [ + r"Item\s+1A\s*[.:]\s*Risk\s+Factors", + r"ITEM\s+1A\s*[.:]\s*Risk\s+Factors", +] +ITEM7_PATTERNS = [ + r"Item\s+7\s*[.:]\s*Management['\u2019]s\s+Discussion\s+and\s+Analysis", + r"ITEM\s+7\s*[.:]\s*Management['\u2019]s\s+Discussion", + r"Item\s+7\s*[.:]\s*[\w\s]+MD&A", +] +ITEM8_PATTERNS = [ + r"Item\s+8\s*[.:]\s*Financial\s+Statements", + r"ITEM\s+8\s*[.:]\s*Financial\s+Statements", +] +ITEM3_PATTERNS = [ + r"Item\s+3\s*[.:]\s*Legal\s+Proceedings", + r"ITEM\s+3\s*[.:]\s*Legal\s+Proceedings", +] +ITEM9A_PATTERNS = [ + r"Item\s+9A\s*[.:]\s*Controls\s+and\s+Procedures", + r"Item\s+9A\s*[.:]\s*Internal\s+Control", + r"ITEM\s+9A\s*[.:]\s*Controls", +] + +# Gemini model config +GEMINI_MODEL = "gemini-2.0-flash" +RATE_LIMIT_WAIT_SEC = 60 + +# Financial statement required keys (for LLM extraction) +REQUIRED_FINANCIAL_KEYS = [ + "Revenue", "CostOfRevenue", "OperatingExpenses", "NetIncome", + "TotalAssets", "CurrentAssets", "CurrentLiabilities", "LongTermDebt", + "OperatingCashFlow", "SharesOutstanding", +] + +# yahooquery: map to our index/column shape (index=line items, columns=dates) +INCOME_ROW_MAP = [ + ("Total Revenue", ("TotalRevenue", "OperatingRevenue", "TotalRevenue")), + ("Cost Of Revenue", ("CostOfRevenue", "ReconciledCostOfRevenue")), + ("Gross Profit", ("GrossProfit",)), + ("Operating Income", ("OperatingIncome", "EBIT", "TotalOperatingIncomeAsReported")), + ("Net Income", ("NetIncome", "NetIncomeCommonStockholders", "NetIncomeContinuousOperations", "DilutedNIAvailtoComStockholders")), + ("Operating Expense", ("OperatingExpense", "OperatingExpenses", "TotalExpenses")), + ("Interest Expense", ("InterestExpense", "InterestExpenseNonOperating")), + ("Research And Development Expenses", ("ResearchAndDevelopment", "ResearchAndDevelopmentExpenses")), +] +BALANCE_ROW_MAP = [ + ("Total Assets", ("TotalAssets",)), + ("Total Stockholder Equity", ("StockholdersEquity", "CommonStockEquity", "TotalEquityGrossMinorityInterest")), + ("Total Liabilities", ("TotalLiabilitiesNetMinorityInterest", "TotalLiabilities")), + ("Current Assets", ("CurrentAssets",)), + ("Current Liabilities", ("CurrentLiabilities",)), + ("Long Term Debt", ("LongTermDebt", "LongTermDebtAndCapitalLeaseObligation")), + ("Total Debt", ("TotalDebt",)), + ("Share Issued", ("OrdinarySharesNumber", "ShareIssued", "BasicAverageShares", "DilutedAverageShares")), + ("Cash And Cash Equivalents", ("CashAndCashEquivalents", "CashCashEquivalentsAndShortTermInvestments", "EndCashPosition")), + ("Retained Earnings", ("RetainedEarnings",)), +] +CASHFLOW_ROW_MAP = [ + ("Operating Cash Flow", ("OperatingCashFlow", "CashFromOperatingActivities")), + ("Capital Expenditure", ("CapitalExpenditure", "CapitalExpenditures")), +] + +# Aswath Damodaran sector WACC (approx. 2024/2025 baseline). Used for reference in DCF panel. +DAMODARAN_WACC = { + "Software": 8.5, + "Retail": 7.5, + "Hardware": 9.0, + "Financials": 8.0, + "Healthcare": 7.2, + "Consumer": 7.5, + "Technology": 8.5, + "Industrial": 7.8, + "Energy": 8.2, + "Utilities": 6.5, +} +DAMODARAN_ERP_PCT = 4.6 +DAMODARAN_RF_PCT = 4.2 diff --git a/config/theme.py b/config/theme.py new file mode 100644 index 0000000..192bb4a --- /dev/null +++ b/config/theme.py @@ -0,0 +1,176 @@ +""" +Soft Navy Professional Financial Terminal Theme — CSS + header HTML. +""" + +SOFT_NAVY_CSS = """ + +""" + +HEADER_HTML = """ +
+ ATLAS + TERMINAL + v2.0 +
+

+ Hybrid: Gemini AI qualitative · yfinance quantitative · Single unified terminal +

+""" diff --git a/data/__init__.py b/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/data/financials.py b/data/financials.py new file mode 100644 index 0000000..c98adc6 --- /dev/null +++ b/data/financials.py @@ -0,0 +1,173 @@ +from typing import Optional +import pandas as pd +import streamlit as st +from utils.formatting import _safe_float +from config.constants import INCOME_ROW_MAP, BALANCE_ROW_MAP, CASHFLOW_ROW_MAP + +try: + import yfinance as yf +except ImportError: + yf = None + +try: + from yahooquery import Ticker as YQTicker +except ImportError: + YQTicker = None + + +def _yq_df_to_our_shape(df: pd.DataFrame, row_map: list, date_col: str = "asOfDate") -> Optional[pd.DataFrame]: + """Convert yahooquery DataFrame (rows=periods, columns=line items) to our shape: index=line names, columns=dates.""" + if df is None or df.empty or date_col not in df.columns: + return None + df = df.dropna(subset=[date_col]).sort_values(date_col, ascending=False).head(5) + if df.empty: + return None + dates = df[date_col].astype(str).str[:10].tolist() + data = {} + for our_name, yq_col in row_map: + cols = (yq_col,) if isinstance(yq_col, str) else yq_col + val_col = next((c for c in cols if c in df.columns), None) + if val_col is None: + data[our_name] = [None] * len(dates) + continue + data[our_name] = [_safe_float(v) for v in df[val_col].tolist()] + out = pd.DataFrame(data, index=dates).T + out.columns = dates + return out + + +def _share_issued_from_yq_balance(df_bal: pd.DataFrame) -> Optional[pd.Series]: + """Try OrdinarySharesNumber then ShareIssued for shares outstanding in yahooquery balance.""" + if df_bal is None or df_bal.empty: + return None + for col in ("OrdinarySharesNumber", "ShareIssued"): + if col in df_bal.columns and "asOfDate" in df_bal.columns: + s = df_bal.set_index("asOfDate")[col].sort_index(ascending=False) + s.index = s.index.astype(str).str[:10] + return s.reindex(s.index) # keep as series with date index + return None + + +@st.cache_data(ttl=300) +def _get_annual_financials_balance_cashflow_yahooquery(ticker: str) -> tuple: + """Fetch income, balance, cash flow from yahooquery. Return (fin_df, bal_df, cf_df) with index=line items, columns=dates. TTM fallback if annual insufficient.""" + if not YQTicker or not ticker: + return (None, None, None) + try: + yq = YQTicker(ticker.upper()) + inc_a = yq.income_statement(frequency="a", trailing=False) + bal_a = yq.balance_sheet(frequency="a", trailing=False) + cf_a = yq.cash_flow(frequency="a", trailing=False) + if inc_a is None or inc_a.empty or bal_a is None or bal_a.empty: + inc_q = yq.income_statement(frequency="q", trailing=False) + bal_q = yq.balance_sheet(frequency="q", trailing=False) + cf_q = yq.cash_flow(frequency="q", trailing=False) + # Build TTM: need at least 2 periods for Piotroski/Radar; use last 4Q and previous 4Q when 8+ quarters + if inc_q is not None and not inc_q.empty and len(inc_q) >= 4: + ttm0 = inc_q.head(4).sum(numeric_only=True) + row0 = ttm0.to_dict() if hasattr(ttm0, "to_dict") else dict(ttm0) + row0["asOfDate"] = inc_q["asOfDate"].iloc[0] if "asOfDate" in inc_q.columns else "TTM0" + rows_inc = [row0] + if len(inc_q) >= 8: + ttm1 = inc_q.iloc[4:8].sum(numeric_only=True) + row1 = ttm1.to_dict() if hasattr(ttm1, "to_dict") else dict(ttm1) + row1["asOfDate"] = inc_q["asOfDate"].iloc[4] if "asOfDate" in inc_q.columns else "TTM1" + rows_inc.append(row1) + inc_a = pd.DataFrame(rows_inc) + if bal_q is not None and not bal_q.empty: + bal_a = bal_q.head(2) if (bal_a is None or bal_a.empty) else bal_a + if cf_q is not None and not cf_q.empty and len(cf_q) >= 4 and (cf_a is None or cf_a.empty): + ttm0_cf = cf_q.head(4).sum(numeric_only=True) + row0_cf = ttm0_cf.to_dict() if hasattr(ttm0_cf, "to_dict") else dict(ttm0_cf) + row0_cf["asOfDate"] = cf_q["asOfDate"].iloc[0] if "asOfDate" in cf_q.columns else "TTM0" + rows_cf = [row0_cf] + if len(cf_q) >= 8: + ttm1_cf = cf_q.iloc[4:8].sum(numeric_only=True) + row1_cf = ttm1_cf.to_dict() if hasattr(ttm1_cf, "to_dict") else dict(ttm1_cf) + row1_cf["asOfDate"] = cf_q["asOfDate"].iloc[4] if "asOfDate" in cf_q.columns else "TTM1" + rows_cf.append(row1_cf) + cf_a = pd.DataFrame(rows_cf) + fin_df = _yq_df_to_our_shape(inc_a, INCOME_ROW_MAP) + bal_df = _yq_df_to_our_shape(bal_a, BALANCE_ROW_MAP) + if bal_df is not None and "Share Issued" not in bal_df.index and bal_a is not None and not bal_a.empty: + for sh_col in ("OrdinarySharesNumber", "ShareIssued"): + if sh_col in bal_a.columns: + row = {"Share Issued": [_safe_float(bal_a[sh_col].iloc[0])]} + if bal_df is not None and not bal_df.empty: + d = str(bal_a["asOfDate"].iloc[0])[:10] if "asOfDate" in bal_a.columns else bal_df.columns[0] + extra = pd.DataFrame(row, index=[d]).T + extra.columns = [d] + bal_df = pd.concat([bal_df, extra], axis=0) + break + cf_df = _yq_df_to_our_shape(cf_a, CASHFLOW_ROW_MAP) + return (fin_df, bal_df, cf_df) + except Exception: + return (None, None, None) + + +# ---------- Raw statements & FCF = OCF - CapEx ---------- +def _get_row_series(df: pd.DataFrame, *names: str) -> Optional[pd.Series]: + if df is None or df.empty: + return None + for name in names: + try: + if name in df.index: + return df.loc[name].copy() + except (KeyError, TypeError): + continue + return None + + +def _fin_or_bal_empty(df) -> bool: + """True if DataFrame is missing, empty, or has no columns (e.g. yfinance returned empty).""" + return df is None or df.empty or (hasattr(df, "columns") and len(df.columns) == 0) + + +@st.cache_data(ttl=300) +def _get_annual_financials_balance_cashflow(ticker: str) -> tuple: + """Return (fin_df, bal_df, cf_df). Uses yahooquery first; if missing/fail, falls back to yfinance with TTM when needed.""" + if not ticker: + return (None, None, None) + fin_df, bal_df, cf_df = _get_annual_financials_balance_cashflow_yahooquery(ticker) + if fin_df is not None and not fin_df.empty and bal_df is not None and not bal_df.empty: + return (fin_df, bal_df, cf_df) + if not yf: + return (None, None, None) + try: + t = yf.Ticker(ticker.upper()) + fin = getattr(t, "financials", None) + bal = getattr(t, "balance_sheet", None) + cf = getattr(t, "cashflow", None) + if _fin_or_bal_empty(fin): + qf = getattr(t, "quarterly_financials", None) + if qf is not None and not qf.empty: + n = len(qf.columns) + if n >= 8: + c0 = qf.iloc[:, :4].sum(axis=1) + c1 = qf.iloc[:, 4:8].sum(axis=1) + fin = pd.concat([c0, c1], axis=1) + fin.columns = ["TTM0", "TTM1"] + elif n >= 5: + c0 = qf.iloc[:, :4].sum(axis=1) + c1 = qf.iloc[:, 4:n].sum(axis=1) + fin = pd.concat([c0, c1], axis=1) + fin.columns = ["TTM0", "TTM1"] + else: + fin = qf.iloc[:, : min(4, n)].sum(axis=1).to_frame("TTM0") + if _fin_or_bal_empty(bal): + qb = getattr(t, "quarterly_balance_sheet", None) + if qb is not None and not qb.empty: + n = len(qb.columns) + bal = qb.iloc[:, : min(2, n)].copy() + if bal.shape[1] == 1: + bal.columns = ["B0"] + else: + bal.columns = ["B0", "B1"] + if _fin_or_bal_empty(cf): + qc = getattr(t, "quarterly_cashflow", None) + if qc is not None and not qc.empty: + n = len(qc.columns) + cf = qc.iloc[:, : min(4, n)].sum(axis=1).to_frame("TTM0") + return (fin, bal, cf) + except Exception: + return (None, None, None) diff --git a/data/fundamentals.py b/data/fundamentals.py new file mode 100644 index 0000000..bf25b5e --- /dev/null +++ b/data/fundamentals.py @@ -0,0 +1,206 @@ +from typing import Optional +import pandas as pd +import streamlit as st +from utils.formatting import _safe_float + +try: + import yfinance as yf +except ImportError: + yf = None + +from data.financials import _get_row_series, _get_annual_financials_balance_cashflow + + +@st.cache_data(ttl=300) +def get_sector_industry(ticker: str) -> dict: + """Return sector and industry from yfinance. Fallback to N/A.""" + if not yf: + return {"sector": "N/A", "industry": "N/A"} + try: + t = yf.Ticker(ticker.upper()) + info = t.info or {} + sector = (info.get("sector") or info.get("sectorDisp") or "N/A").strip() or "N/A" + industry = (info.get("industry") or info.get("industryDisp") or "N/A").strip() or "N/A" + return {"sector": sector, "industry": industry} + except Exception: + return {"sector": "N/A", "industry": "N/A"} + + +@st.cache_data(ttl=300) +def get_5yr_financial_trend(ticker: str) -> pd.DataFrame: + """Extract up to 5 years: Revenue, Net Income, Operating Margin, FCF (OCF - CapEx). Handles missing years.""" + if not yf: + return pd.DataFrame() + try: + t = yf.Ticker(ticker.upper()) + financials = t.financials # annual + cashflow = t.cashflow + if financials is None or financials.empty or cashflow is None or cashflow.empty: + return pd.DataFrame() + dates = sorted(financials.columns.tolist(), reverse=True)[:5] + ocf = _get_row_series(cashflow, "Operating Cash Flow", "Cash From Operating Activities", "Cash From Operations") + capx = _get_row_series(cashflow, "Capital Expenditure", "Capital Expenditures", "Purchase Of Property Plant And Equipment") + revenue = _get_row_series(financials, "Total Revenue", "Revenue", "Net Revenue") + ni = _get_row_series(financials, "Net Income", "Net Income Common Stockholders") + op_income = _get_row_series(financials, "Operating Income", "EBIT") + rows = [] + cashflow_cols = list(cashflow.columns) if cashflow is not None else [] + for d in dates: + yr = d.year if hasattr(d, "year") else int(str(d)[:4]) + rev = _safe_float(revenue.get(d)) if revenue is not None and d in revenue.index else None + net_i = _safe_float(ni.get(d)) if ni is not None and d in ni.index else None + op_i = _safe_float(op_income.get(d)) if op_income is not None and d in op_income.index else None + oper_margin = (op_i / rev * 100) if (op_i is not None and rev and rev != 0) else ((net_i / rev * 100) if (net_i is not None and rev and rev != 0) else None) + ocf_val = _safe_float(ocf.get(d)) if ocf is not None and d in ocf.index else None + if ocf_val is None and ocf is not None and cashflow_cols: + for c in cashflow_cols: + if (getattr(c, "year", None) or int(str(c)[:4])) == yr: + ocf_val = _safe_float(ocf.get(c)) + break + capx_val = _safe_float(capx.get(d)) if capx is not None and d in capx.index else None + if capx_val is None and capx is not None and cashflow_cols: + for c in cashflow_cols: + if (getattr(c, "year", None) or int(str(c)[:4])) == yr: + capx_val = _safe_float(capx.get(c)) + break + if ocf_val is not None and capx_val is not None: + fcf = ocf_val - capx_val + elif ocf_val is not None: + fcf = ocf_val + else: + fcf = None + rows.append({ + "Year": yr, + "Revenue": rev, + "Net Income": net_i, + "Operating Margin %": round(oper_margin, 2) if oper_margin is not None else None, + "FCF": fcf, + }) + return pd.DataFrame(rows) + except Exception: + return pd.DataFrame() + + +@st.cache_data(ttl=300) +def get_dcf_inputs(ticker: str) -> dict: + """FCF, Cash, Total Debt, Shares: from yahooquery (via _get_annual_financials) or yfinance fallback.""" + out = {"fcf": None, "total_debt": 0.0, "cash": 0.0, "shares": None} + if not ticker: + return out + try: + fin, bal, cf = _get_annual_financials_balance_cashflow(ticker) + if bal is not None and not bal.empty and cf is not None and not cf.empty: + sh = _get_row_series(bal, "Share Issued") + out["shares"] = _safe_float(sh.iloc[0]) if sh is not None and len(sh) > 0 else None + td = _get_row_series(bal, "Total Debt") + out["total_debt"] = float(td.iloc[0] or 0) if td is not None and len(td) > 0 else 0.0 + cash_s = _get_row_series(bal, "Cash And Cash Equivalents") + out["cash"] = float(cash_s.iloc[0] or 0) if cash_s is not None and len(cash_s) > 0 else 0.0 + ocf = _get_row_series(cf, "Operating Cash Flow") + capx = _get_row_series(cf, "Capital Expenditure") + if ocf is not None and len(ocf) > 0: + ocf_val = _safe_float(ocf.iloc[0]) + capx_val = _safe_float(capx.iloc[0]) if capx is not None and len(capx) > 0 else 0.0 + if ocf_val is not None: + out["fcf"] = ocf_val - (capx_val or 0) + if out.get("fcf") is not None or out.get("shares") is not None: + return out + except Exception: + pass + if not yf: + return out + try: + t = yf.Ticker(ticker.upper()) + info = t.info or {} + fast_info = getattr(t, "fast_info", None) + cashflow = getattr(t, "cashflow", None) + if cashflow is None or cashflow.empty: + cashflow = getattr(t, "quarterly_cashflow", None) + balance = getattr(t, "balance_sheet", None) + if balance is None or balance.empty: + balance = getattr(t, "quarterly_balance_sheet", None) + + # ----- Shares Outstanding: multi-step fallback (no manual by default) ----- + shares = None + if fast_info is not None: + try: + s = getattr(fast_info, "shares", None) + if s is None and hasattr(fast_info, "get"): + s = fast_info.get("shares") + if s is not None and float(s) > 0: + shares = float(s) + except (TypeError, ValueError, AttributeError): + pass + if shares is None: + for key in ("sharesOutstanding", "Shares Outstanding", "impliedSharesOutstanding", "Float Shares"): + s = info.get(key) + if s is not None and float(s) > 0: + shares = float(s) + break + if shares is None and balance is not None and not balance.empty: + try: + if "Share Issued" in balance.index: + shares = _safe_float(balance.loc["Share Issued"].iloc[0]) + if (shares is None or shares <= 0) and "Ordinary Shares Number" in balance.index: + shares = _safe_float(balance.loc["Ordinary Shares Number"].iloc[0]) + except (KeyError, TypeError, IndexError): + pass + out["shares"] = shares if (shares is not None and shares > 0) else None + + # ----- Total Debt: fast_info → info → balance ----- + total_debt = None + if fast_info is not None: + try: + d = getattr(fast_info, "total_debt", None) or (fast_info.get("total_debt") if hasattr(fast_info, "get") else None) + if d is not None and float(d) >= 0: + total_debt = float(d) + except (TypeError, ValueError, AttributeError): + pass + if total_debt is None: + total_debt = info.get("Total Debt") + if total_debt is None and balance is not None and not balance.empty: + try: + if "Total Debt" in balance.index: + total_debt = _safe_float(balance.loc["Total Debt"].iloc[0]) + except (KeyError, TypeError, IndexError): + pass + out["total_debt"] = float(total_debt) if total_debt is not None else 0.0 + + # ----- Cash: fast_info → info → balance ----- + cash = None + if fast_info is not None: + try: + c = getattr(fast_info, "cash", None) or (fast_info.get("cash") if hasattr(fast_info, "get") else None) + if c is not None and float(c) >= 0: + cash = float(c) + except (TypeError, ValueError, AttributeError): + pass + if cash is None: + cash = info.get("Cash And Cash Equivalents") or info.get("Cash") + if cash is None and balance is not None and not balance.empty: + try: + for row in ("Cash And Cash Equivalents", "Cash Cash Equivalents And Short Term Investments", "Cash"): + if row in balance.index: + cash = _safe_float(balance.loc[row].iloc[0]) + if cash is not None: + break + except (KeyError, TypeError, IndexError): + pass + out["cash"] = float(cash) if cash is not None else 0.0 + + # ----- Base FCF = OCF - CapEx ----- + ocf = _get_row_series(cashflow, "Operating Cash Flow", "Cash From Operating Activities", "Cash From Operations") if cashflow is not None else None + capx = _get_row_series(cashflow, "Capital Expenditure", "Capital Expenditures", "Purchase Of Property Plant And Equipment") if cashflow is not None else None + if ocf is not None and len(ocf) > 0: + latest_date = ocf.index[0] + ocf_val = _safe_float(ocf.iloc[0]) + capx_val = _safe_float(capx.get(latest_date)) if (capx is not None and hasattr(capx, "index") and latest_date in getattr(capx, "index", [])) else (_safe_float(capx.iloc[0]) if capx is not None and len(capx) > 0 else None) + if capx_val is None: + capx_val = 0.0 + if ocf_val is not None: + latest_fcf = ocf_val - capx_val + if latest_fcf == latest_fcf and not (isinstance(latest_fcf, float) and pd.isna(latest_fcf)): + out["fcf"] = latest_fcf + return out + except Exception: + return out diff --git a/data/market.py b/data/market.py new file mode 100644 index 0000000..0b3263e --- /dev/null +++ b/data/market.py @@ -0,0 +1,106 @@ +import streamlit as st +import pandas as pd +from utils.formatting import _safe_float + +try: + import yfinance as yf +except ImportError: + yf = None + + +@st.cache_data(ttl=300) +def get_technical_indicators(ticker: str) -> dict: + """RSI(14), SMA(50), SMA(200), support/resistance, 52-week range.""" + out = {"rsi_14": None, "sma_50": None, "sma_200": None, "current_price": None, "support": None, "resistance": None, "52w_high": None, "52w_low": None} + if not yf or not ticker: + return out + try: + t = yf.Ticker(ticker.upper()) + hist = t.history(period="1y") + if hist is None or hist.empty or len(hist) < 14: + return out + close = hist["Close"] + out["current_price"] = float(close.iloc[-1]) + delta = close.diff() + gain = delta.where(delta > 0, 0).rolling(window=14).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean() + rs = gain / loss + rsi = 100 - (100 / (1 + rs)) + out["rsi_14"] = round(float(rsi.iloc[-1]), 1) if not pd.isna(rsi.iloc[-1]) else None + if len(close) >= 50: + out["sma_50"] = round(float(close.rolling(50).mean().iloc[-1]), 2) + if len(close) >= 200: + out["sma_200"] = round(float(close.rolling(200).mean().iloc[-1]), 2) + out["52w_high"] = round(float(close.max()), 2) + out["52w_low"] = round(float(close.min()), 2) + recent = close.tail(20) + out["support"] = round(float(recent.min()), 2) + out["resistance"] = round(float(recent.max()), 2) + return out + except Exception: + return out + + +@st.cache_data(ttl=600) +def get_risk_analysis(ticker: str) -> list: + """Risk factors with estimated EPS impact.""" + if not yf or not ticker: + return [] + try: + t = yf.Ticker(ticker.upper()) + info = t.info or {} + eps = info.get("trailingEps") or info.get("forwardEps") or 1.0 + beta = info.get("beta") or 1.0 + debt_equity = info.get("debtToEquity") or 0 + margin = info.get("operatingMargins") or 0 + risks = [] + impact = round(eps * (beta - 1) * 0.1, 2) if beta > 1 else round(eps * 0.05, 2) + risks.append({"risk": "Market / Macro Risk", "severity": "High" if beta > 1.3 else "Medium", "eps_impact": f"-${abs(impact):.2f}", "description": f"Beta {beta:.2f}"}) + comp_impact = round(eps * 0.08, 2) + risks.append({"risk": "Competitive Pressure", "severity": "High" if margin < 0.15 else "Medium", "eps_impact": f"-${abs(comp_impact):.2f}", "description": f"Op margin {margin*100:.1f}%"}) + lev_impact = round(eps * 0.06, 2) if debt_equity and debt_equity > 100 else round(eps * 0.03, 2) + risks.append({"risk": "Financial / Leverage", "severity": "High" if (debt_equity or 0) > 150 else ("Medium" if (debt_equity or 0) > 80 else "Low"), "eps_impact": f"-${abs(lev_impact):.2f}", "description": f"D/E {debt_equity:.0f}%" if debt_equity else "D/E N/A"}) + risks.append({"risk": "Regulatory / Legal", "severity": "Medium", "eps_impact": f"-${abs(round(eps * 0.05, 2)):.2f}", "description": "Regulatory changes"}) + risks.append({"risk": "Currency / FX", "severity": "Medium", "eps_impact": f"-${abs(round(eps * 0.04, 2)):.2f}", "description": "FX exposure"}) + risks.append({"risk": "Supply Chain", "severity": "Medium", "eps_impact": f"-${abs(round(eps * 0.05, 2)):.2f}", "description": "Component/logistics risk"}) + return risks + except Exception: + return [] + + +@st.cache_data(ttl=120) +def _get_ticker_bar_data() -> list: + """Fetch major index/crypto prices for top ticker bar.""" + items = [] + tickers_bar = {"S&P 500": "^GSPC", "NASDAQ": "^IXIC", "KOSPI": "^KS11", "NIKKEI": "^N225", "BTC": "BTC-USD", "ETH": "ETH-USD"} + for label, sym in tickers_bar.items(): + try: + t = yf.Ticker(sym) + info = t.info or {} + price = info.get("regularMarketPrice") or info.get("previousClose") or 0 + prev = info.get("regularMarketPreviousClose") or info.get("previousClose") or price + change_pct = ((price - prev) / prev * 100) if prev else 0 + items.append({"label": label, "price": price, "change": change_pct}) + except Exception: + items.append({"label": label, "price": 0, "change": 0}) + return items + + +@st.cache_data(ttl=300) +def _fetch_news_rss(ticker_sym: str, company_name: str = "") -> list: + """Fetch news from Google News RSS. Returns list of {title, source, url, published}.""" + import feedparser + items = [] + query = ticker_sym if not company_name else company_name + try: + feed = feedparser.parse(f"https://news.google.com/rss/search?q={query}+stock&hl=en-US&gl=US&ceid=US:en") + for entry in (feed.entries or [])[:15]: + items.append({ + "title": entry.get("title", ""), + "source": entry.get("source", {}).get("title", "Google News") if hasattr(entry.get("source", ""), "get") else "Google News", + "url": entry.get("link", ""), + "published": entry.get("published", ""), + }) + except Exception: + pass + return items diff --git a/data/ratios.py b/data/ratios.py new file mode 100644 index 0000000..66213f6 --- /dev/null +++ b/data/ratios.py @@ -0,0 +1,285 @@ +from typing import Optional +import pandas as pd +import streamlit as st +from utils.formatting import _safe_float, _na +from data.financials import _get_row_series, _get_annual_financials_balance_cashflow + +try: + import yfinance as yf +except ImportError: + yf = None + + +@st.cache_data(ttl=300) +def get_comps_data(tickers: tuple) -> pd.DataFrame: + """Fetch Forward P/E, EV/EBITDA, P/B using forwardPE, enterpriseToEbitda, priceToBook. Missing → None (display as N/A). Robust per-ticker error handling.""" + if not yf: + return pd.DataFrame() + rows = [] + for sym in tickers: + sym = str(sym).strip().upper() + if not sym: + continue + try: + t = yf.Ticker(sym) + info = t.info or {} + forward_pe = info.get("forwardPE") or info.get("Forward PE") or info.get("trailingPE") or info.get("Trailing PE") + ev_ebitda = info.get("enterpriseToEbitda") + if ev_ebitda is None: + ev, ebitda = info.get("enterpriseValue"), info.get("ebitda") + if ev is not None and ebitda is not None and ebitda != 0: + ev_ebitda = ev / ebitda + pb = info.get("priceToBook") or info.get("Price To Book") + rows.append({ + "Ticker": sym, + "Forward P/E": round(float(forward_pe), 2) if forward_pe is not None and _safe_float(forward_pe) is not None else None, + "EV/EBITDA": round(float(ev_ebitda), 2) if ev_ebitda is not None and _safe_float(ev_ebitda) is not None else None, + "P/B": round(float(pb), 2) if pb is not None and _safe_float(pb) is not None else None, + }) + except Exception: + rows.append({"Ticker": sym, "Forward P/E": None, "EV/EBITDA": None, "P/B": None}) + if not rows: + return pd.DataFrame() + return pd.DataFrame(rows) + + +@st.cache_data(ttl=300) +def get_dupont_altman_redflags_yoy(ticker: str) -> dict: + """Returns DuPont (3-step ROE), Altman Z-Score, red flags, YoY. Uses yahooquery then yfinance with TTM fallback.""" + try: + fin, bal, _ = _get_annual_financials_balance_cashflow(ticker) + if fin is None or fin.empty or bal is None or bal.empty: + return {} + t = yf.Ticker(ticker.upper()) + info = t.info or {} + # TTM columns: keep order TTM0 (current), TTM1 (prior). Else use date sort (newest first). + col_list = fin.columns.tolist() + if col_list and str(col_list[0]).startswith("TTM"): + dates = col_list[:3] + else: + dates = sorted(col_list, reverse=True)[:3] + if not dates: + return {} + rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue") + ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders") + ebit = _get_row_series(fin, "Operating Income", "EBIT") + gross = _get_row_series(fin, "Gross Profit") + interest = _get_row_series(fin, "Interest Expense", "Interest Expense Net") + total_assets = _get_row_series(bal, "Total Assets") + total_equity = _get_row_series(bal, "Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest") + current_assets = _get_row_series(bal, "Current Assets") + current_liab = _get_row_series(bal, "Current Liabilities") + retained = _get_row_series(bal, "Retained Earnings") + total_liab = _get_row_series(bal, "Total Liabilities") + market_cap = info.get("marketCap") or info.get("Market Cap") + def _v(s, d): + if s is None or d not in s.index: + return None + return _safe_float(s.get(d)) + rows = [] + for i, d in enumerate(dates): + yr = int(str(d)[:4]) if (isinstance(d, str) and str(d)[:4].isdigit()) else (d.year if hasattr(d, "year") else (2024 - i)) + r = _v(rev, d) + net_i = _v(ni, d) + ta = _v(total_assets, d) + te = _v(total_equity, d) + if ta and ta > 0 and te and te > 0 and r and r != 0: + npm = (net_i / r * 100) if net_i is not None else None + at = r / ta if r and ta else None + em = ta / te if ta and te else None + roe = (net_i / te * 100) if (net_i and te) else (npm * at * em / 100 if (npm and at and em) else None) + else: + npm = at = em = roe = None + gross_p = _v(gross, d) + gross_margin = (gross_p / r * 100) if (gross_p and r and r != 0) else None + op_inc = _v(ebit, d) + op_margin = (op_inc / r * 100) if (op_inc and r and r != 0) else None + ca = _v(current_assets, d) + cl = _v(current_liab, d) + current_ratio = (ca / cl) if (ca and cl and cl != 0) else None + int_exp = _v(interest, d) + if op_inc is not None and int_exp is not None and int_exp != 0: + _ic = op_inc / int_exp + interest_cov = round(_ic, 2) if (_ic == _ic and not (isinstance(_ic, float) and (pd.isna(_ic) or _ic != _ic))) else None + else: + interest_cov = None # N/A when Interest Expense is 0 or missing (avoid nan%) + rows.append({ + "Year": yr, + "Revenue": r, "Net Income": net_i, + "NPM %": round(npm, 2) if npm is not None else None, + "Asset Turnover": round(at, 4) if at is not None else None, + "Equity Mult.": round(em, 2) if em is not None else None, + "ROE %": round(roe, 2) if roe is not None else None, + "Gross Margin %": round(gross_margin, 2) if gross_margin is not None else None, + "Operating Margin %": round(op_margin, 2) if op_margin is not None else None, + "Current Ratio": round(current_ratio, 2) if current_ratio is not None else None, + "Interest Coverage": interest_cov, + }) + dupont_df = pd.DataFrame(rows) + yoy = [] + if len(dupont_df) >= 2: + for col in ["NPM %", "ROE %", "Gross Margin %", "Operating Margin %", "Current Ratio", "Interest Coverage"]: + if col not in dupont_df.columns: + continue + cur = dupont_df[col].iloc[0] + prev = dupont_df[col].iloc[1] + if cur is not None and prev is not None and prev != 0 and not (pd.isna(cur) or pd.isna(prev)): + if "Margin" in col or "NPM" in col or "ROE" in col: + chg_pp = (cur - prev) # percentage point change (e.g. 7.0 = 7%) + if pd.isna(chg_pp) or chg_pp != chg_pp: + continue + yoy.append({"Ratio": col, "Latest": cur, "Prior": prev, "YoY (pp)": round(chg_pp, 2), "Comment": f"{'Improved' if chg_pp > 0 else 'Declined'} by {abs(chg_pp):.1f}% YoY"}) + else: + pct = (cur - prev) / abs(prev) * 100 + if pd.isna(pct) or pct != pct: + continue + yoy.append({"Ratio": col, "Latest": cur, "Prior": prev, "YoY %": round(pct, 1), "Comment": f"{'Up' if pct > 0 else 'Down'} {abs(round(pct, 1))}% YoY"}) + latest_bal_d = bal.columns[0] + wc = (_v(current_assets, latest_bal_d) or 0) - (_v(current_liab, latest_bal_d) or 0) + ta_l = _v(total_assets, latest_bal_d) + re_l = _v(retained, latest_bal_d) + tl_l = _v(total_liab, latest_bal_d) + ebit_l = _v(ebit, fin.columns[0]) + sales_l = _v(rev, fin.columns[0]) + altman_z = None + if ta_l and ta_l > 0 and market_cap is not None and tl_l and tl_l != 0 and sales_l: + a = wc / ta_l + b = (re_l or 0) / ta_l + c = (ebit_l or 0) / ta_l + d = market_cap / tl_l + e = sales_l / ta_l + altman_z = 1.2 * a + 1.4 * b + 3.3 * c + 0.6 * d + 1.0 * e + red_flags = [] + if len(dupont_df) > 0: + row0 = dupont_df.iloc[0] + cr = row0.get("Current Ratio") + if cr is not None and cr < 1.0: + red_flags.append({"metric": "Current Ratio", "value": cr, "threshold": 1.0, "flag": "WARNING", "comment": "Current assets do not cover current liabilities; liquidity risk."}) + ic = row0.get("Interest Coverage") + if ic is not None and ic < 1.5: + red_flags.append({"metric": "Interest Coverage", "value": ic, "threshold": 1.5, "flag": "WARNING", "comment": "EBIT barely covers interest; default risk."}) + return { + "dupont": dupont_df, + "yoy": yoy, + "altman_z": round(altman_z, 2) if altman_z is not None else None, + "red_flags": red_flags, + } + except (KeyError, TypeError, ZeroDivisionError, IndexError) as e: + return {} + except Exception: + return {} + + +@st.cache_data(ttl=300) +def get_quarterly_momentum(ticker: str) -> dict: + """Last 4 quarters Revenue and Net Income from quarterly_financials; QoQ growth for most recent quarter. Returns {df, qoq_revenue_pct, qoq_ni_pct} or empty.""" + out = {"df": None, "qoq_revenue_pct": None, "qoq_ni_pct": None} + if not yf or not ticker: + return out + try: + t = yf.Ticker(ticker.upper()) + qfin = getattr(t, "quarterly_financials", None) + if qfin is None or qfin.empty or len(qfin.columns) < 2: + return out + rev = _get_row_series(qfin, "Total Revenue", "Revenue", "Net Revenue") + ni = _get_row_series(qfin, "Net Income", "Net Income Common Stockholders") + if rev is None and ni is None: + return out + cols = list(qfin.columns)[:4] + rows = [] + for c in cols: + try: + if hasattr(c, "strftime"): + q = (c.month - 1) // 3 + 1 if hasattr(c, "month") else 1 + label = c.strftime("%Y") + f"-Q{q}" + else: + label = str(c)[:12] + except Exception: + label = str(c)[:12] + r_val = _safe_float(rev.loc[c]) if rev is not None and c in rev.index else None + n_val = _safe_float(ni.loc[c]) if ni is not None and c in ni.index else None + rows.append({"Quarter": label, "Revenue": r_val, "Net Income": n_val}) + out["df"] = pd.DataFrame(rows) + if len(rows) >= 2: + r0, r1 = rows[0].get("Revenue"), rows[1].get("Revenue") + n0, n1 = rows[0].get("Net Income"), rows[1].get("Net Income") + if r0 is not None and r1 is not None and r1 != 0: + out["qoq_revenue_pct"] = round((r0 - r1) / abs(r1) * 100, 1) + if n0 is not None and n1 is not None and n1 != 0: + out["qoq_ni_pct"] = round((n0 - n1) / abs(n1) * 100, 1) + return out + except Exception: + return out + + +@st.cache_data(ttl=300) +def get_quarterly_ratio_changes(ticker: str) -> list: + """QoQ ratio changes: NPM %, ROE %, Gross Margin %, Operating Margin %, Current Ratio, Interest Coverage. Latest quarter vs previous. Returns list of {Metric, Current, Change, Trend}.""" + out = [] + if not yf or not ticker: + return out + try: + t = yf.Ticker(ticker.upper()) + qf = getattr(t, "quarterly_financials", None) + qb = getattr(t, "quarterly_balance_sheet", None) + if qf is None or qf.empty or qb is None or qb.empty or len(qf.columns) < 2 or len(qb.columns) < 2: + return out + rev = _get_row_series(qf, "Total Revenue", "Revenue", "Net Revenue") + ni = _get_row_series(qf, "Net Income", "Net Income Common Stockholders") + gross = _get_row_series(qf, "Gross Profit") + ebit = _get_row_series(qf, "Operating Income", "EBIT") + interest = _get_row_series(qf, "Interest Expense", "Interest Expense Net") + ta = _get_row_series(qb, "Total Assets") + te = _get_row_series(qb, "Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest") + ca = _get_row_series(qb, "Current Assets") + cl = _get_row_series(qb, "Current Liabilities") + def v(s, col): + if s is None or col not in s.index: + return None + return _safe_float(s.get(col)) + c0, c1 = qf.columns[0], qf.columns[1] + b0, b1 = qb.columns[0], qb.columns[1] + r0, r1 = v(rev, c0), v(rev, c1) + n0, n1 = v(ni, c0), v(ni, c1) + g0, g1 = v(gross, c0), v(gross, c1) + e0, e1 = v(ebit, c0), v(ebit, c1) + i0, i1 = v(interest, c0), v(interest, c1) + ta0, ta1 = v(ta, b0), v(ta, b1) + te0, te1 = v(te, b0), v(te, b1) + ca0, ca1 = v(ca, b0), v(ca, b1) + cl0, cl1 = v(cl, b0), v(cl, b1) + npm0 = (n0 / r0 * 100) if (n0 is not None and r0 and r0 != 0) else None + npm1 = (n1 / r1 * 100) if (n1 is not None and r1 and r1 != 0) else None + roe0 = (n0 / te0 * 100) if (n0 is not None and te0 and te0 != 0) else None + roe1 = (n1 / te1 * 100) if (n1 is not None and te1 and te1 != 0) else None + gm0 = (g0 / r0 * 100) if (g0 is not None and r0 and r0 != 0) else None + gm1 = (g1 / r1 * 100) if (g1 is not None and r1 and r1 != 0) else None + om0 = (e0 / r0 * 100) if (e0 is not None and r0 and r0 != 0) else None + om1 = (e1 / r1 * 100) if (e1 is not None and r1 and r1 != 0) else None + cr0 = (ca0 / cl0) if (ca0 is not None and cl0 and cl0 != 0) else None + cr1 = (ca1 / cl1) if (ca1 is not None and cl1 and cl1 != 0) else None + ic0 = (e0 / i0) if (e0 is not None and i0 and i0 != 0) else None + ic1 = (e1 / i1) if (e1 is not None and i1 and i1 != 0) else None + def row(metric, cur, prev, is_pct_point=False): + if cur is None: + return None + cur_str = f"{round(cur, 2):.2f}" + if prev is None or (is_pct_point and prev != prev): + return {"Metric": metric, "Current Value": cur_str, "Change": "—", "Trend": "—"} + if is_pct_point: + chg = cur - prev + else: + chg = ((cur - prev) / abs(prev) * 100) if prev != 0 else 0 + trend = "↑" if chg > 0 else ("↓" if chg < 0 else "—") + chg_str = f"{chg:+.1f}%" if not is_pct_point else f"{chg:+.1f} pp" + return {"Metric": metric, "Current Value": cur_str, "Change": chg_str, "Trend": trend} + for name, cur, prev, is_pp in [ + ("NPM %", npm0, npm1, True), ("ROE %", roe0, roe1, True), ("Gross Margin %", gm0, gm1, True), + ("Operating Margin %", om0, om1, True), ("Current Ratio", cr0, cr1, False), ("Interest Coverage", ic0, ic1, False), + ]: + r = row(name, cur, prev, is_pp) + if r: + out.append(r) + return out + except Exception: + return out diff --git a/data/scores.py b/data/scores.py new file mode 100644 index 0000000..fe0b318 --- /dev/null +++ b/data/scores.py @@ -0,0 +1,261 @@ +from typing import Optional +import pandas as pd +import streamlit as st +from utils.formatting import _safe_float +from data.financials import _get_row_series, _get_annual_financials_balance_cashflow +from data.ratios import get_dupont_altman_redflags_yoy + +try: + import yfinance as yf +except ImportError: + yf = None + + +@st.cache_data(ttl=300) +def get_income_statement_sankey_data(ticker: str) -> dict: + """Latest year (or TTM): Revenue, COGS, Gross Profit, OpEx, Operating Income, Tax/Interest/Other, Net Income. Uses yahooquery then yfinance.""" + out = {"revenue": 0, "cogs": 0, "gross_profit": 0, "opex": 0, "operating_income": 0, "tax_interest_other": 0, "net_income": 0} + fin, _, _ = _get_annual_financials_balance_cashflow(ticker) + if fin is None or fin.empty: + return out + try: + rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue") + cogs = _get_row_series(fin, "Cost Of Revenue", "Cost Of Goods Sold") + gross = _get_row_series(fin, "Gross Profit") + op_inc = _get_row_series(fin, "Operating Income", "EBIT") + ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders") + if rev is None or len(rev) == 0: + return out + d = rev.index[0] + revenue = abs(_safe_float(rev.get(d)) or 0) + cogs_val = abs(_safe_float(cogs.get(d)) if cogs is not None and d in cogs.index else 0) or 0 + gross_val = _safe_float(gross.get(d)) if gross is not None and d in gross.index else None + if gross_val is None and revenue and cogs_val is not None: + gross_val = revenue - cogs_val + elif gross_val is None: + gross_val = revenue + gross_val = abs(gross_val) if gross_val is not None else 0 + op_inc_val = _safe_float(op_inc.get(d)) if op_inc is not None and d in op_inc.index else None + op_inc_val = op_inc_val if op_inc_val is not None else 0 + ni_val = _safe_float(ni.get(d)) if ni is not None and d in ni.index else None + ni_val = ni_val if ni_val is not None else 0 + opex_val = max(0, gross_val - op_inc_val) if (gross_val >= op_inc_val) else 0 + tax_interest_other = max(0, op_inc_val - ni_val) if (op_inc_val - ni_val) > 0 else abs(min(0, op_inc_val - ni_val)) + out["revenue"] = max(revenue, 1) + out["cogs"] = min(cogs_val, revenue - 1e-6) + out["gross_profit"] = gross_val + out["opex"] = opex_val + out["operating_income"] = op_inc_val + out["tax_interest_other"] = tax_interest_other + out["net_income"] = ni_val + return out + except Exception: + return out + + +# sankey_data_from_ai, piotroski_from_ai, radar_metrics_from_ai → data/scores_ai.py + + +@st.cache_data(ttl=300) +def get_radar_metrics_normalized(ticker: str) -> dict: + """ROE, Current Ratio, Asset Turnover, Equity Mult, Revenue YoY. Normalized to 0-100 for radar. Returns {theta: [...], r: [...], labels: [...]} or empty.""" + if not ticker: + return {} + q = get_dupont_altman_redflags_yoy(ticker) + if not q: + return {} + dupont_df = q.get("dupont") + if dupont_df is None or dupont_df.empty or len(dupont_df) < 2: + return {} + row0 = dupont_df.iloc[0] + row1 = dupont_df.iloc[1] + roe = row0.get("ROE %") or 0 + cr = row0.get("Current Ratio") or 0 + at = row0.get("Asset Turnover") or 0 + em = row0.get("Equity Mult.") or 0 + rev0 = dupont_df["Revenue"].iloc[0] if "Revenue" in dupont_df.columns else None + rev1 = dupont_df["Revenue"].iloc[1] if "Revenue" in dupont_df.columns else None + rev_yoy = ((rev0 - rev1) / rev1 * 100) if (rev0 and rev1 and rev1 != 0) else 0 + def norm_roe(x): + if x is None: return 50 + return min(100, max(0, (x + 10) / 40 * 100)) + def norm_cr(x): + if x is None: return 50 + return min(100, max(0, x / 3 * 100)) + def norm_at(x): + if x is None: return 50 + return min(100, max(0, x * 50)) + def norm_em(x): + if x is None: return 50 + return min(100, max(0, (x - 0.5) / 2.5 * 100)) + def norm_yoy(x): + if x is None: return 50 + return min(100, max(0, (x + 20) / 50 * 100)) + return { + "theta": ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"], + "r": [norm_roe(roe), norm_cr(cr), norm_at(at), norm_em(em), norm_yoy(rev_yoy)], + "labels": ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"], + } + + +def _build_radar_figure(ticker: str) -> "go.Figure": + """Plotly radar chart from ticker data.""" + from utils.charts import _build_radar_common + data = get_radar_metrics_normalized(ticker) + if not data or not data.get("r"): + return None + return _build_radar_common(data["theta"], data["r"]) + + +@st.cache_data(ttl=300) +def get_piotroski_fscore(ticker: str) -> dict: + """Piotroski F-Score (0-9) from last 2 periods. Uses yahooquery then yfinance with TTM fallback. Returns score + criteria + used_ttm.""" + out = {"score": 0, "criteria": [], "used_ttm": False} + fin, bal, cf = _get_annual_financials_balance_cashflow(ticker) + if fin is None or fin.empty or bal is None or bal.empty: + return out + if cf is None or cf.empty: + cf = pd.DataFrame() + try: + ncol = min(2, len(fin.columns)) + rev = _get_row_series(fin, "Total Revenue", "Revenue") + ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders") + gross = _get_row_series(fin, "Gross Profit") + ta = _get_row_series(bal, "Total Assets") + lt_debt = _get_row_series(bal, "Long Term Debt") + ca = _get_row_series(bal, "Current Assets") + cl = _get_row_series(bal, "Current Liabilities") + ocf = _get_row_series(cf, "Operating Cash Flow", "Cash From Operating Activities") if not cf.empty else None + shares = _get_row_series(bal, "Share Issued") or _get_row_series(bal, "Ordinary Shares Number") + if shares is None and yf: + t = yf.Ticker(ticker.upper()) + info = getattr(t, "info", None) or {} + sh_info = info.get("sharesOutstanding") or info.get("Shares Outstanding") + if sh_info is not None: + try: + sh_float = float(sh_info) + shares = pd.Series([sh_float] * ncol, index=fin.columns[:ncol]) + except (TypeError, ValueError): + pass + def v0(s): + if s is None or len(s) == 0: + return None + x = _safe_float(s.iloc[0]) + return x if (x is not None and x == x and not (isinstance(x, float) and pd.isna(x))) else None + def v1(s): + if s is None or len(s) < 2: + return None + x = _safe_float(s.iloc[1]) + return x if (x is not None and x == x and not (isinstance(x, float) and pd.isna(x))) else None + ni0, ni1 = v0(ni), v1(ni) + ocf0 = v0(ocf) if ocf is not None else None + ta0, ta1 = v0(ta), v1(ta) + roa0 = (ni0 / ta0 * 100) if (ni0 is not None and ta0 is not None and ta0 != 0) else None + roa1 = (ni1 / ta1 * 100) if (ni1 is not None and ta1 is not None and ta1 != 0) else None + c1 = (ni0 is not None and ni0 > 0) + c2 = (ocf0 is not None and ocf0 > 0) + c3 = (roa0 is not None and roa1 is not None and roa0 > roa1) + c4 = (ocf0 is not None and ni0 is not None and ocf0 > ni0) + lt0 = v0(lt_debt) or 0 + lt1 = v1(lt_debt) or 0 + c5 = (ta0 is not None and ta0 != 0 and ta1 is not None and ta1 != 0 and (lt0 / ta0) < (lt1 / ta1)) + cl0, cl1 = v0(cl), v1(cl) + ca0, ca1 = v0(ca), v1(ca) + cr0 = (ca0 / cl0) if (ca0 is not None and cl0 is not None and cl0 != 0) else None + cr1 = (ca1 / cl1) if (ca1 is not None and cl1 is not None and cl1 != 0) else None + c6 = (cr0 is not None and cr1 is not None and cr0 > cr1) + sh0, sh1 = v0(shares), v1(shares) + c7 = (sh0 is not None and sh1 is not None and sh0 <= sh1) if (sh0 is not None and sh1 is not None) else True + rev0, rev1 = v0(rev), v1(rev) + gm0 = (v0(gross) / rev0 * 100) if (gross is not None and rev0 is not None and rev0 != 0) else None + gm1 = (v1(gross) / rev1 * 100) if (gross is not None and rev1 is not None and rev1 != 0) else None + c8 = (gm0 is not None and gm1 is not None and gm0 > gm1) + at0 = (rev0 / ta0) if (rev0 is not None and ta0 is not None and ta0 != 0) else None + at1 = (rev1 / ta1) if (rev1 is not None and ta1 is not None and ta1 != 0) else None + c9 = (at0 is not None and at1 is not None and at0 > at1) + criteria = [ + ("Net Income > 0 (profitability)", c1), + ("Operating Cash Flow > 0 (cash generative)", c2), + ("ROA increased vs prior period (improving returns)", c3), + ("OCF > Net Income (earnings quality, less accruals)", c4), + ("Leverage decreased: LT Debt/Assets lower (less debt)", c5), + ("Current Ratio improved (better liquidity)", c6), + ("No dilution: shares unchanged or lower (no equity raise)", c7), + ("Gross Margin improved (pricing power)", c8), + ("Asset Turnover improved (efficiency)", c9), + ] + score = sum(1 for _, p in criteria if p) + out["score"] = score + out["criteria"] = criteria + out["used_ttm"] = bool(fin is not None and hasattr(fin, "columns") and len(fin.columns) > 0 and any(str(c).startswith("TTM") for c in fin.columns)) + return out + except Exception: + out["used_ttm"] = False + return out + + +@st.cache_data(ttl=300) +def get_sector_specific_metrics(ticker: str, sector: str) -> dict: + """Technology: Rule of 40, R&D % revenue. Retail/Consumer: Inventory Turnover, Operating Margin. Financials: ROE, ROA.""" + if not yf: + return {} + try: + t = yf.Ticker(ticker.upper()) + info = t.info or {} + fin = t.financials + bal = t.balance_sheet + if fin is None or fin.empty: + fin = getattr(t, "quarterly_financials", None) + if fin is not None and not fin.empty: + fin = fin.iloc[:, :4].sum(axis=1).to_frame() + if bal is None or bal.empty: + bal = getattr(t, "quarterly_balance_sheet", None) + out = {} + sector_lower = (sector or "").lower() + if "technology" in sector_lower or "software" in sector_lower or "tech" in sector_lower: + rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue") + ocf = _get_row_series(t.cashflow or getattr(t, "quarterly_cashflow", None), "Operating Cash Flow", "Cash From Operating Activities") + capx = _get_row_series(t.cashflow or getattr(t, "quarterly_cashflow", None), "Capital Expenditure", "Capital Expenditures") + rd = _get_row_series(fin, "Research And Development", "Research And Development Expense") + if rev is not None and len(rev) > 0: + r0 = _safe_float(rev.iloc[0]) + if ocf is not None and len(ocf) > 0 and capx is not None and len(capx) > 0: + fcf = _safe_float(ocf.iloc[0]) - _safe_float(capx.iloc[0]) + out["FCF Margin %"] = round(fcf / r0 * 100, 2) if r0 and fcf is not None else None + if rd is not None and len(rd) > 0: + out["R&D % of Revenue"] = round(_safe_float(rd.iloc[0]) / r0 * 100, 2) if r0 else None + rev_growth = None + if rev is not None and len(rev) >= 2: + cur, prev = _safe_float(rev.iloc[0]), _safe_float(rev.iloc[1]) + if prev and prev != 0: + rev_growth = (cur - prev) / prev * 100 + if rev_growth is not None and "FCF Margin %" in out and out["FCF Margin %"] is not None: + out["Rule of 40 (Rev Growth + FCF Margin)"] = round(rev_growth + out["FCF Margin %"], 1) + if "consumer" in sector_lower or "retail" in sector_lower or "cyclical" in sector_lower: + inv = _get_row_series(bal, "Inventory", "Total Inventory") + cogs = _get_row_series(fin, "Cost Of Revenue", "Cost Of Goods Sold", "Cost of Goods Sold") + rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue") + op_inc = _get_row_series(fin, "Operating Income", "EBIT") + if inv is not None and len(inv) > 0 and cogs is not None and len(cogs) > 0: + inv0 = _safe_float(inv.iloc[0]) + cogs0 = _safe_float(cogs.iloc[0]) + out["Inventory Turnover"] = round(cogs0 / inv0, 2) if inv0 else None + if rev is not None and len(rev) > 0 and op_inc is not None and len(op_inc) > 0: + r0 = _safe_float(rev.iloc[0]) + op0 = _safe_float(op_inc.iloc[0]) + out["Operating Margin %"] = round(op0 / r0 * 100, 2) if r0 else None + if "financial" in sector_lower or "bank" in sector_lower or "insurance" in sector_lower: + ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders") + te = _get_row_series(bal, "Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest") + ta = _get_row_series(bal, "Total Assets") + if ni is not None and te is not None and len(ni) > 0 and len(te) > 0: + te0 = _safe_float(te.iloc[0]) + ni0 = _safe_float(ni.iloc[0]) + out["ROE %"] = round(ni0 / te0 * 100, 2) if te0 else None + if ni is not None and ta is not None and len(ni) > 0 and len(ta) > 0: + ta0 = _safe_float(ta.iloc[0]) + ni0 = _safe_float(ni.iloc[0]) + out["ROA %"] = round(ni0 / ta0 * 100, 2) if ta0 else None + return out + except Exception: + return {} diff --git a/data/scores_ai.py b/data/scores_ai.py new file mode 100644 index 0000000..d34547b --- /dev/null +++ b/data/scores_ai.py @@ -0,0 +1,98 @@ +""" +AI-derived score/chart helper functions: build Sankey, Piotroski, Radar data from Gemini-extracted financials. +""" +from utils.charts import _radar_norm + + +def sankey_data_from_ai(ai_dict: dict) -> dict: + """Build Sankey input dict from get_sec_financials_llm result (current_yr). Gross Profit = Revenue - CostOfRevenue; Operating Income = Gross Profit - OperatingExpenses.""" + out = {"revenue": 0, "cogs": 0, "gross_profit": 0, "opex": 0, "operating_income": 0, "tax_interest_other": 0, "net_income": 0} + cur = (ai_dict or {}).get("current_yr") or {} + revenue = max(0, (cur.get("Revenue") or 0)) + cogs = max(0, min(cur.get("CostOfRevenue") or 0, revenue - 1e-6)) + gross_profit = revenue - cogs + opex = max(0, cur.get("OperatingExpenses") or 0) + operating_income = gross_profit - opex + net_income = cur.get("NetIncome") or 0 + tax_interest_other = max(0, operating_income - net_income) if operating_income > net_income else abs(min(0, operating_income - net_income)) + out["revenue"] = max(revenue, 1) + out["cogs"] = cogs + out["gross_profit"] = gross_profit + out["opex"] = opex + out["operating_income"] = operating_income + out["tax_interest_other"] = tax_interest_other + out["net_income"] = net_income + return out + + +def piotroski_from_ai(ai_dict: dict) -> dict: + """Piotroski F-Score (0-9) from AI-extracted current_yr vs previous_yr. Returns {score, criteria, used_ttm: True}.""" + out = {"score": 0, "criteria": [], "used_ttm": True} + cur = (ai_dict or {}).get("current_yr") or {} + prev = (ai_dict or {}).get("previous_yr") or {} + if not cur: + return out + def v(d, k): return (d.get(k) or 0) + ni0, ni1 = v(cur, "NetIncome"), v(prev, "NetIncome") + ocf0 = v(cur, "OperatingCashFlow") + ta0, ta1 = v(cur, "TotalAssets"), v(prev, "TotalAssets") + roa0 = (ni0 / ta0 * 100) if ta0 and ta0 != 0 else None + roa1 = (ni1 / ta1 * 100) if ta1 and ta1 != 0 else None + c1 = ni0 > 0 + c2 = ocf0 > 0 + c3 = (roa0 is not None and roa1 is not None and roa0 > roa1) + c4 = ocf0 > ni0 + lt0, lt1 = v(cur, "LongTermDebt"), v(prev, "LongTermDebt") + c5 = (ta0 and ta1 and (lt0 / ta0) < (lt1 / ta1)) if ta0 and ta1 else False + ca0, ca1 = v(cur, "CurrentAssets"), v(prev, "CurrentAssets") + cl0, cl1 = v(cur, "CurrentLiabilities"), v(prev, "CurrentLiabilities") + cr0 = (ca0 / cl0) if cl0 and cl0 != 0 else None + cr1 = (ca1 / cl1) if cl1 and cl1 != 0 else None + c6 = (cr0 is not None and cr1 is not None and cr0 > cr1) + sh0, sh1 = v(cur, "SharesOutstanding"), v(prev, "SharesOutstanding") + c7 = (sh0 <= sh1) if (sh0 and sh1) else True + rev0, rev1 = v(cur, "Revenue"), v(prev, "Revenue") + gm0 = ((rev0 - v(cur, "CostOfRevenue")) / rev0 * 100) if rev0 and rev0 != 0 else None + gm1 = ((rev1 - v(prev, "CostOfRevenue")) / rev1 * 100) if rev1 and rev1 != 0 else None + c8 = (gm0 is not None and gm1 is not None and gm0 > gm1) + at0 = (rev0 / ta0) if rev0 and ta0 and ta0 != 0 else None + at1 = (rev1 / ta1) if rev1 and ta1 and ta1 != 0 else None + c9 = (at0 is not None and at1 is not None and at0 > at1) + criteria = [ + ("Net Income > 0 (profitability)", c1), + ("Operating Cash Flow > 0 (cash generative)", c2), + ("ROA increased vs prior period (improving returns)", c3), + ("OCF > Net Income (earnings quality, less accruals)", c4), + ("Leverage decreased: LT Debt/Assets lower (less debt)", c5), + ("Current Ratio improved (better liquidity)", c6), + ("No dilution: shares unchanged or lower (no equity raise)", c7), + ("Gross Margin improved (pricing power)", c8), + ("Asset Turnover improved (efficiency)", c9), + ] + out["score"] = sum(1 for _, p in criteria if p) + out["criteria"] = criteria + return out + + +def radar_metrics_from_ai(ai_dict: dict) -> dict: + """ROE, Current Ratio, Asset Turnover, Equity Mult, Revenue YoY from AI dict; normalized 0-100 for radar. Equity proxy: TotalAssets - CurrentLiabilities - LongTermDebt.""" + cur = (ai_dict or {}).get("current_yr") or {} + prev = (ai_dict or {}).get("previous_yr") or {} + if not cur: + return {} + eq0 = (cur.get("TotalAssets") or 0) - (cur.get("CurrentLiabilities") or 0) - (cur.get("LongTermDebt") or 0) + if eq0 <= 0: + eq0 = (cur.get("TotalAssets") or 0) * 0.5 + roe = (cur.get("NetIncome") or 0) / eq0 * 100 if eq0 else 0 + ca, cl = cur.get("CurrentAssets") or 0, cur.get("CurrentLiabilities") or 0 + current_ratio = (ca / cl) if cl and cl != 0 else 0 + ta = cur.get("TotalAssets") or 1 + asset_turnover = (cur.get("Revenue") or 0) / ta + equity_mult = (cur.get("TotalAssets") or 0) / eq0 if eq0 else 0 + rev0, rev1 = cur.get("Revenue") or 0, prev.get("Revenue") or 0 + rev_yoy = ((rev0 - rev1) / rev1 * 100) if rev1 and rev1 != 0 else 0 + return { + "theta": ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"], + "r": _radar_norm(roe, current_ratio, asset_turnover, equity_mult, rev_yoy), + "labels": ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"], + } diff --git a/data/sec_downloader.py b/data/sec_downloader.py new file mode 100644 index 0000000..aa5954e --- /dev/null +++ b/data/sec_downloader.py @@ -0,0 +1,112 @@ +""" +SEC 10-K download + section extraction: download via sec-edgar-downloader, extract items, cache. +""" +import tempfile +from pathlib import Path + +from config.constants import ( + ITEM1A_PATTERNS, ITEM3_PATTERNS, ITEM7_PATTERNS, ITEM8_PATTERNS, ITEM9A_PATTERNS, +) +from data.sec_parser import ( + find_item_section_generic, _find_section_start, + _extract_item_from_full, clean_text_for_llm, smart_chunk, +) +from data.sec_fetcher import ( + get_edgar_downloader, find_downloaded_10k_path, find_all_10k_filing_dirs, + get_main_10k_text, _load_10k_html_from_cache, _get_main_10k_html_file, + _save_10k_html_to_cache, _load_10k_from_cache, _save_10k_to_cache, +) + + +def download_and_extract_all_items(ticker: str, email: str) -> dict: + """Download latest 10-K, extract Item 1A, 3, 7, 9A; clean and return (and optionally cache). + Also saves the raw HTML file to data/TICKER_latest_raw.html for the native viewer.""" + Downloader = get_edgar_downloader() + with tempfile.TemporaryDirectory() as tmpdir: + download_root = Path(tmpdir) + dl = Downloader("FQDC-10K-Analyzer", email, str(download_root)) + dl.get("10-K", ticker.upper(), limit=1, download_details=True) + filing_dir = find_downloaded_10k_path(download_root, ticker) + if not filing_dir: + raise FileNotFoundError(f"Could not find 10-K for ticker '{ticker}'.") + full_text = get_main_10k_text(filing_dir) + if not full_text: + raise ValueError("Could not extract text from the 10-K.") + # Save raw HTML to persistent cache while temp dir is still open + if not _load_10k_html_from_cache(ticker): + main_html_path = _get_main_10k_html_file(filing_dir) + if main_html_path: + try: + with open(main_html_path, "r", encoding="utf-8", errors="replace") as _f: + _save_10k_html_to_cache(ticker, _f.read()) + except Exception: + pass + item1a = find_item_section_generic(full_text, ITEM1A_PATTERNS, 1, ["Risk", "Factors"], max_chars=80000) + item3 = _extract_item_from_full(full_text, ITEM3_PATTERNS, 3, ["Legal", "Proceedings"], max_chars=40000) + item9a = _extract_item_from_full(full_text, ITEM9A_PATTERNS, 9, ["Controls", "Procedures", "Internal"], max_chars=40000) + start7 = _find_section_start(full_text, ITEM7_PATTERNS, 7) + text_after_7 = full_text[start7:] if start7 >= 0 else full_text + item7 = find_item_section_generic(text_after_7, ITEM7_PATTERNS, 7, ["Management's Discussion", "MD&A", "Analysis"], max_chars=100000) + if not item7 and text_after_7: + item7 = text_after_7[:120000] + item8 = _extract_item_from_full(full_text, ITEM8_PATTERNS, 8, ["Financial Statements", "Supplementary Data"], max_chars=200000) + data = { + "item1a": clean_text_for_llm(item1a or ""), + "item3": clean_text_for_llm(item3 or ""), + "item9a": clean_text_for_llm(item9a or ""), + "item7": clean_text_for_llm(item7 or ""), + "item8": clean_text_for_llm(item8 or ""), + } + _save_10k_to_cache(ticker, data) + return data + + +def get_10k_sections(ticker: str, email: str) -> tuple: + """Return (sections dict, status). status = 'cache' if loaded from file else 'downloaded'.""" + cached = _load_10k_from_cache(ticker) + if cached is not None: + return cached, "cache" + return download_and_extract_all_items(ticker, email), "downloaded" + + +def download_and_extract_item7_and_1a(ticker: str, email: str) -> tuple: + """Fetch 10-K and return full_text, Item 1A (Risk Factors), Item 7 (MD&A). Uses cache when available.""" + sections, _ = get_10k_sections(ticker, email) + return "", sections.get("item1a", "") or "", sections.get("item7", "") or "" + + +def download_item7_latest_and_3y_ago(ticker: str, email: str) -> tuple: + """Download up to 5 10-Ks; extract Item 1A (latest only) and Item 7 from latest and from 3 years ago.""" + Downloader = get_edgar_downloader() + with tempfile.TemporaryDirectory() as tmpdir: + download_root = Path(tmpdir) + dl = Downloader("FQDC-10K-Analyzer", email, str(download_root)) + dl.get("10-K", ticker.upper(), limit=5, download_details=True) + filing_dirs = find_all_10k_filing_dirs(download_root, ticker) + if not filing_dirs: + raise FileNotFoundError(f"Could not find 10-K for ticker '{ticker}'.") + full_latest = get_main_10k_text(filing_dirs[0]) + if not full_latest: + raise ValueError("Could not extract text from the latest 10-K.") + item1a = find_item_section_generic( + full_latest, ITEM1A_PATTERNS, 1, ["Risk", "Factors"], max_chars=80000 + ) + text_after_7 = full_latest[_find_section_start(full_latest, ITEM7_PATTERNS, 7):] if _find_section_start(full_latest, ITEM7_PATTERNS, 7) >= 0 else full_latest + item7_latest = find_item_section_generic( + text_after_7, ITEM7_PATTERNS, 7, ["Management's Discussion", "MD&A", "Analysis"], max_chars=100000 + ) + if not item7_latest and text_after_7: + item7_latest = smart_chunk(text_after_7[:120000], max_chars=20000) + item7_3y_ago = None + has_comparison = False + if len(filing_dirs) >= 4: + full_3y = get_main_10k_text(filing_dirs[3]) + if full_3y: + text_3y = full_3y[_find_section_start(full_3y, ITEM7_PATTERNS, 7):] if _find_section_start(full_3y, ITEM7_PATTERNS, 7) >= 0 else full_3y + item7_3y_ago = find_item_section_generic( + text_3y, ITEM7_PATTERNS, 7, ["Management's Discussion", "MD&A", "Analysis"], max_chars=100000 + ) + if not item7_3y_ago and text_3y: + item7_3y_ago = smart_chunk(text_3y[:120000], max_chars=20000) + has_comparison = bool(item7_3y_ago) + return item1a or "", item7_latest or "", item7_3y_ago, has_comparison diff --git a/data/sec_fetcher.py b/data/sec_fetcher.py new file mode 100644 index 0000000..7148333 --- /dev/null +++ b/data/sec_fetcher.py @@ -0,0 +1,252 @@ +""" +SEC EDGAR download, fetch, cache: 10-K download via sec-edgar-downloader, EDGAR API HTML fetch, disk cache. +""" +import json +import re +import tempfile +import requests +from pathlib import Path +from typing import Optional + +from utils.prefs import _DATA_DIR +from config.constants import ( + ITEM1A_PATTERNS, ITEM3_PATTERNS, ITEM7_PATTERNS, ITEM8_PATTERNS, ITEM9A_PATTERNS, +) +from data.sec_parser import ( + extract_text_from_file, find_item_section_generic, _find_section_start, + _extract_item_from_full, clean_text_for_llm, +) + + +def get_edgar_downloader(): + from sec_edgar_downloader import Downloader + return Downloader + + +def find_downloaded_10k_path(download_root: Path, ticker: str) -> Optional[Path]: + ticker_upper = ticker.upper() + for base in (download_root / "sec-edgar-filings", download_root): + path_10k = base / ticker_upper / "10-K" + if path_10k.exists(): + subdirs = sorted([d for d in path_10k.iterdir() if d.is_dir()], key=lambda x: x.name, reverse=True) + if subdirs: + return subdirs[0] + for base in (download_root / "sec-edgar-filings", download_root): + if not base.exists(): + continue + for company_dir in base.iterdir(): + if not company_dir.is_dir(): + continue + path_10k = company_dir / "10-K" + if path_10k.exists(): + subdirs = sorted([d for d in path_10k.iterdir() if d.is_dir()], key=lambda x: x.name, reverse=True) + if subdirs: + return subdirs[0] + return None + + +def find_all_10k_filing_dirs(download_root: Path, ticker: str) -> list: + """Return list of 10-K filing dirs sorted newest first (for multi-year comparison).""" + ticker_upper = ticker.upper() + for base in (download_root / "sec-edgar-filings", download_root): + path_10k = base / ticker_upper / "10-K" + if path_10k.exists(): + subdirs = sorted([d for d in path_10k.iterdir() if d.is_dir()], key=lambda x: x.name, reverse=True) + return subdirs + return [] + + +def get_main_10k_text(filing_dir: Path) -> str: + all_text = [] + for ext in ("*.htm", "*.html", "*.txt"): + for path in filing_dir.rglob(ext): + try: + t = extract_text_from_file(path) + if len(t) > 1000: + all_text.append((path, t)) + except Exception: + continue + if not all_text: + return "" + _, main_text = max(all_text, key=lambda x: len(x[1])) + return main_text + + +def _get_10k_cache_path(ticker: str) -> Path: + """Path for cached 10-K sections: data/TICKER_latest.json.""" + _DATA_DIR.mkdir(parents=True, exist_ok=True) + return _DATA_DIR / f"{ticker.upper()}_latest.json" + + +def _get_10k_html_cache_path(ticker: str) -> Path: + """Path for cached raw 10-K HTML: data/TICKER_latest_raw.html.""" + _DATA_DIR.mkdir(parents=True, exist_ok=True) + return _DATA_DIR / f"{ticker.upper()}_latest_raw.html" + + +def _load_10k_html_from_cache(ticker: str) -> Optional[str]: + """Load raw 10-K HTML from data/TICKER_latest_raw.html. Returns None if missing.""" + path = _get_10k_html_cache_path(ticker) + if not path.exists(): + return None + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + return f.read() + except Exception: + return None + + +def _save_10k_html_to_cache(ticker: str, html: str) -> None: + """Save raw 10-K HTML to data/TICKER_latest_raw.html.""" + path = _get_10k_html_cache_path(ticker) + _DATA_DIR.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8", errors="replace") as f: + f.write(html) + + +def fetch_sec_filing_html(ticker: str, filing_type: str = "10-K") -> dict: + """Fetch raw SEC filing HTML from EDGAR public API. + + Returns dict with keys: + - html: str | None (the raw HTML content) + - error: str | None (human-readable error for st.error()) + - doc_url: str | None (final document URL for reference) + - source: 'cache' | 'edgar_api' | None + """ + # Check disk cache (only for 10-K for backward compat) + if filing_type == "10-K": + cached = _load_10k_html_from_cache(ticker) + if cached: + return {"html": cached, "error": None, "doc_url": None, "source": "cache"} + + # SEC requires: "Company Name (contact@email.com)" format + headers = { + "User-Agent": "FQDC-Terminal (atlas-terminal@fqdc.io)", + "Accept-Encoding": "gzip, deflate", + } + + # Step 1: ticker -> CIK + url_tickers = "https://www.sec.gov/files/company_tickers.json" + r = requests.get(url_tickers, headers={**headers, "Accept": "application/json"}, timeout=15) + if not r.ok: + return {"html": None, "error": f"SEC tickers lookup failed: HTTP {r.status_code}", "doc_url": url_tickers, "source": None} + cik = None + for entry in r.json().values(): + if entry.get("ticker", "").upper() == ticker.upper(): + cik = str(entry["cik_str"]).zfill(10) + break + if not cik: + return {"html": None, "error": f"Ticker '{ticker}' not found in SEC company_tickers.json", "doc_url": None, "source": None} + + # Step 2: find latest filing of requested type + primaryDocument + url_submissions = f"https://data.sec.gov/submissions/CIK{cik}.json" + r = requests.get(url_submissions, headers={**headers, "Accept": "application/json"}, timeout=15) + if not r.ok: + return {"html": None, "error": f"SEC submissions API failed: HTTP {r.status_code} for CIK {cik}", "doc_url": url_submissions, "source": None} + filings = r.json().get("filings", {}).get("recent", {}) + forms = filings.get("form", []) + accessions = filings.get("accessionNumber", []) + primary_docs = filings.get("primaryDocument", []) + filing_dates = filings.get("filingDate", []) + + accession = None + main_doc = None + filing_date = None + for form, acc, pdoc, fdate in zip(forms, accessions, primary_docs, filing_dates): + if form == filing_type: + accession = acc.replace("-", "") + main_doc = pdoc + filing_date = fdate + break + if not accession or not main_doc: + return {"html": None, "error": f"No '{filing_type}' filing found for {ticker} (CIK {cik})", "doc_url": None, "source": None} + + # Step 3: download the primary .htm document + doc_url = f"https://www.sec.gov/Archives/edgar/data/{int(cik)}/{accession}/{main_doc}" + r = requests.get(doc_url, headers={**headers, "Accept": "text/html,application/xhtml+xml"}, timeout=90) + if not r.ok: + return {"html": None, "error": f"SEC document download failed: HTTP {r.status_code} for {doc_url}", "doc_url": doc_url, "source": None} + + html_content = r.text + if not html_content or len(html_content) < 500: + return {"html": None, "error": f"SEC returned empty/tiny document ({len(html_content)} bytes) from {doc_url}", "doc_url": doc_url, "source": None} + + # Cache to disk (10-K only) + if filing_type == "10-K": + _save_10k_html_to_cache(ticker, html_content) + + return {"html": html_content, "error": None, "doc_url": doc_url, "source": "edgar_api", "filing_date": filing_date} + + +def _wrap_edgar_html_for_iframe(raw_html: str, ticker: str) -> str: + """Inject a minimal CSS reset so EDGAR HTML renders cleanly inside components.html().""" + inject_css = """ + +""" + # If the HTML has a , inject after it. Otherwise prepend. + if "" in raw_html.lower(): + raw_html = raw_html.replace("", f"{inject_css}", 1) + elif "{inject_css}{raw_html}" + return raw_html + + +def _load_10k_from_cache(ticker: str) -> Optional[dict]: + """Load Item 1A, 3, 7, 8, 9A (plain text) from data/ticker_latest.json. Returns None if missing.""" + path = _get_10k_cache_path(ticker) + if not path.exists(): + return None + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return None + + +def _save_10k_to_cache(ticker: str, data: dict) -> None: + """Save cleaned 10-K sections to data/ticker_latest.json.""" + path = _get_10k_cache_path(ticker) + _DATA_DIR.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=0) + + +def _get_main_10k_html_file(filing_dir: Path) -> Optional[Path]: + """Return the Path of the largest .htm/.html file in the filing dir (the main document).""" + candidates = [] + for ext in ("*.htm", "*.html"): + for p in filing_dir.rglob(ext): + try: + candidates.append((p.stat().st_size, p)) + except Exception: + pass + if not candidates: + return None + candidates.sort(reverse=True) + return candidates[0][1] + + +# Download & extraction functions moved to data/sec_downloader.py: +# download_and_extract_all_items, get_10k_sections, +# download_and_extract_item7_and_1a, download_item7_latest_and_3y_ago diff --git a/data/sec_parser.py b/data/sec_parser.py new file mode 100644 index 0000000..a6c25eb --- /dev/null +++ b/data/sec_parser.py @@ -0,0 +1,150 @@ +""" +SEC 10-K text extraction: HTML parsing, item section extraction, text cleaning. +""" +import re +from pathlib import Path + +from bs4 import BeautifulSoup + +from config.constants import ( + ITEM1A_PATTERNS, ITEM3_PATTERNS, ITEM7_PATTERNS, ITEM8_PATTERNS, ITEM9A_PATTERNS, +) + + +def _slice_html_items_1a_to_9a(raw_html: str) -> str: + """Fast string slice: keep only Item 1A through end of Item 9A to avoid parsing 50MB+ full file. Uses .find()/regex on raw string only.""" + if not raw_html or len(raw_html) < 5000: + return raw_html + start = -1 + for needle in ("Item 1A", "ITEM 1A", "Item 1a"): + i = raw_html.find(needle) + if i != -1 and (start == -1 or i < start): + start = i + if start == -1: + m = re.search(r"Item\s+1A\s", raw_html, re.IGNORECASE) + start = m.start() if m else 0 + else: + start = max(0, start - 200) + search_region = raw_html[start:] + end_match = re.search(r"Item\s+10\s|Item\s+12\s|Part\s+III\b|PART\s+III\b", search_region, re.IGNORECASE) + end = start + end_match.start() if end_match else len(raw_html) + end = min(end, start + 8_000_000) + return raw_html[start:end] + + +def _extract_text_from_html_string(html_str: str) -> str: + """Parse HTML string with lxml; drop table/img/svg/style/script immediately to reduce memory and speed.""" + if not html_str or not html_str.strip(): + return "" + try: + soup = BeautifulSoup(html_str, "lxml") + except Exception: + soup = BeautifulSoup(html_str, "html.parser") + for tag in soup.find_all(["table", "img", "svg", "style", "script"]): + tag.decompose() + return soup.get_text(separator="\n", strip=True) + + +def extract_text_from_html(html_path: Path) -> str: + try: + with open(html_path, "r", encoding="utf-8", errors="replace") as f: + raw = f.read() + except Exception: + with open(html_path, "r", encoding="latin-1", errors="replace") as f: + raw = f.read() + chunk = _slice_html_items_1a_to_9a(raw) + return _extract_text_from_html_string(chunk) + + +def extract_text_from_file(file_path: Path) -> str: + suf = file_path.suffix.lower() + if suf in (".htm", ".html"): + return extract_text_from_html(file_path) + if suf == ".txt": + with open(file_path, "r", encoding="utf-8", errors="replace") as f: + text = f.read() + text = re.sub(r"<[^>]+>", " ", text) + text = re.sub(r"\s+", " ", text) + return text + return "" + + +def _find_section_start(text: str, patterns: list, item_num: int) -> int: + for pat in patterns: + m = re.search(pat, text, re.IGNORECASE) + if m: + return m.start() + m = re.search(r"\bItem\s+" + str(item_num) + r"\b", text, re.IGNORECASE) + return m.start() if m else -1 + + +def find_item_section_generic(text: str, patterns: list, item_num: int, title_keywords: list, max_chars: int = 120000) -> str: + start = _find_section_start(text, patterns, item_num) + if start == -1: + pattern = re.compile( + r"\bItem\s+" + str(item_num) + r"\b[.\s]*[^\n]*(" + "|".join(re.escape(k) for k in title_keywords) + r")?", + re.IGNORECASE, + ) + match = pattern.search(text) + if not match: + return "" + start = match.start() + next_item = re.search(r"\n\s*Item\s+\d+[A-Z]?\s+", text[start + 100:], re.IGNORECASE) + if next_item: + end = start + 100 + next_item.start() + else: + end = min(start + max_chars, len(text)) + return text[start:end].strip() + + +def clean_text_for_llm(html_content: str) -> str: + """Aggressive cleaning for LLM: strip tables/code, collapse whitespace, drop non-ASCII. Uses lxml for speed; drops table/img/svg/style/script.""" + if not html_content or not html_content.strip(): + return "" + try: + soup = BeautifulSoup(html_content, "lxml") + for tag in soup.find_all(["table", "img", "style", "script", "svg", "math"]): + tag.decompose() + text = soup.get_text(separator=" ") + except Exception: + text = re.sub(r"<[^>]+>", " ", html_content) + text = re.sub(r"\s+", " ", text) + text = " ".join(text.split()) + text = re.sub(r"[^\x20-\x7E\n]", " ", text) + text = re.sub(r"\s+", " ", text).strip() + lines = [] + for line in text.split("\n"): + line = line.strip() + if not line: + continue + if re.fullmatch(r"\d+", line) or re.fullmatch(r"[\.\-\s\-]+", line): + continue + if re.match(r"^(page\s+\d+|\d+)\s*$", line, re.IGNORECASE) and len(line) < 20: + continue + lines.append(line) + result = " ".join(lines) + result = re.sub(r"\s+", " ", result).strip() + return result + + +def smart_chunk(section: str, max_chars: int = 10000, head_ratio: float = 0.5) -> str: + """Limit payload for Gemini; 10k chars ~ 2.5k tokens for fast response.""" + if not section or len(section) <= max_chars: + return section + head_size = int(max_chars * head_ratio) + tail_size = max_chars - head_size - 100 + return section[:head_size] + " [ ... middle omitted ... ] " + section[-tail_size:] + + +def _extract_item_from_full(text: str, patterns: list, item_num: int, keywords: list, max_chars: int = 60000) -> str: + """Extract one item section from full 10-K text.""" + start = _find_section_start(text, patterns, item_num) + if start < 0: + pattern = re.compile(r"\bItem\s+" + str(item_num) + r"[A-Z]?\b[.\s]*[^\n]*", re.IGNORECASE) + match = pattern.search(text) + start = match.start() if match else -1 + if start < 0: + return "" + next_item = re.search(r"\n\s*Item\s+\d+[A-Z]?\s+", text[start + 100:], re.IGNORECASE) + end = start + 100 + next_item.start() if next_item else min(start + max_chars, len(text)) + return text[start:end].strip() diff --git a/data/valuation.py b/data/valuation.py new file mode 100644 index 0000000..eedcedb --- /dev/null +++ b/data/valuation.py @@ -0,0 +1,172 @@ +from typing import Optional +import pandas as pd +import streamlit as st +from utils.formatting import _safe_float +from data.financials import _get_row_series + +try: + import yfinance as yf +except ImportError: + yf = None + + +@st.cache_data(ttl=300) +def get_analyst_consensus(ticker: str) -> dict: + """Fetch analyst consensus from yfinance.""" + out = {"targetMeanPrice": None, "targetHighPrice": None, "targetLowPrice": None, "recommendationKey": "N/A", "revenueGrowth": "N/A", "earningsGrowth": "N/A", "numberOfAnalystOpinions": "N/A", "currentPrice": None} + if not yf or not ticker: + return out + try: + t = yf.Ticker(ticker.upper()) + info = t.info or {} + for key in ("targetMeanPrice", "targetHighPrice", "targetLowPrice"): + v = info.get(key) + if v is not None: + try: + out[key] = float(v) + except (TypeError, ValueError): + pass + out["currentPrice"] = info.get("currentPrice") or info.get("regularMarketPrice") or info.get("previousClose") + out["numberOfAnalystOpinions"] = info.get("numberOfAnalystOpinions") or "N/A" + rec = info.get("recommendationKey") or info.get("recommendation") + if rec is not None: + out["recommendationKey"] = str(rec) + rg = info.get("revenueGrowth") + if rg is not None: + try: + out["revenueGrowth"] = f"{float(rg) * 100:.1f}%" + except (TypeError, ValueError): + out["revenueGrowth"] = str(rg) + eg = info.get("earningsGrowth") + if eg is not None: + try: + out["earningsGrowth"] = f"{float(eg) * 100:.1f}%" + except (TypeError, ValueError): + out["earningsGrowth"] = str(eg) + return out + except Exception: + return out + + +@st.cache_data(ttl=300) +def get_dcf_smart_defaults(ticker: str) -> dict: + """Smart default assumptions: WACC from CAPM (Beta), Terminal Growth = 2.5%, FCF Growth from revenueGrowth/earningsGrowth or 8%.""" + out = {"wacc_pct": 10.0, "term_growth_pct": 2.5, "fcf_growth_pct": 8.0} + if not yf or not ticker: + return out + try: + t = yf.Ticker(ticker.upper()) + info = t.info or {} + beta = info.get("beta") + if beta is None: + beta = 1.0 + else: + try: + beta = float(beta) + except (TypeError, ValueError): + beta = 1.0 + risk_free = 4.0 + market_risk_premium = 5.0 + calculated_wacc = risk_free + (beta * market_risk_premium) + out["wacc_pct"] = round(min(20.0, max(4.0, calculated_wacc)), 1) + out["term_growth_pct"] = 2.5 + rev_growth = info.get("revenueGrowth") or info.get("earningsGrowth") + if rev_growth is not None: + try: + g = float(rev_growth) + out["fcf_growth_pct"] = round(min(30.0, max(-10.0, g * 100)), 1) + except (TypeError, ValueError): + pass + return out + except Exception: + return out + + +@st.cache_data(ttl=300) +def get_fcff_fcfe_valuation(ticker: str) -> dict: + """FCFF/FCFE two-stage valuation model. Returns dict with fcff, fcfe, and per-share values.""" + if not yf: + return {} + try: + t = yf.Ticker(ticker.upper()) + info = t.info or {} + fin = t.financials + bal = t.balance_sheet + cf = t.cashflow + if fin is None or fin.empty: + return {} + # Get latest year data + rev = _get_row_series(fin, "Total Revenue", "Revenue") + ebit = _get_row_series(fin, "EBIT", "Operating Income") + ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders") + ocf = _get_row_series(cf, "Operating Cash Flow", "Cash From Operating Activities") if cf is not None else None + capx = _get_row_series(cf, "Capital Expenditure", "Capital Expenditures") if cf is not None else None + dep = _get_row_series(cf, "Depreciation And Amortization", "Depreciation & Amortization") if cf is not None else None + + r0 = _safe_float(rev.iloc[0]) if rev is not None and len(rev) > 0 else None + ebit0 = _safe_float(ebit.iloc[0]) if ebit is not None and len(ebit) > 0 else None + ni0 = _safe_float(ni.iloc[0]) if ni is not None and len(ni) > 0 else None + ocf0 = _safe_float(ocf.iloc[0]) if ocf is not None and len(ocf) > 0 else None + capx0 = abs(_safe_float(capx.iloc[0]) or 0) if capx is not None and len(capx) > 0 else 0 + dep0 = _safe_float(dep.iloc[0]) if dep is not None and len(dep) > 0 else 0 + + # Tax rate estimation + tax_expense = _get_row_series(fin, "Tax Provision", "Income Tax Expense") + pretax = _get_row_series(fin, "Pretax Income", "Income Before Tax") + tax_rate = 0.21 # default US corporate + if tax_expense is not None and pretax is not None and len(tax_expense) > 0 and len(pretax) > 0: + te = _safe_float(tax_expense.iloc[0]) + pt = _safe_float(pretax.iloc[0]) + if pt and pt > 0 and te is not None: + tax_rate = min(max(te / pt, 0.05), 0.40) + + # Balance sheet items + total_debt_series = _get_row_series(bal, "Total Debt") if bal is not None else None + cash_series = _get_row_series(bal, "Cash And Cash Equivalents", "Cash Cash Equivalents And Short Term Investments") if bal is not None else None + equity_series = _get_row_series(bal, "Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest") if bal is not None else None + + total_debt = _safe_float(total_debt_series.iloc[0]) if total_debt_series is not None and len(total_debt_series) > 0 else 0 + cash_val = _safe_float(cash_series.iloc[0]) if cash_series is not None and len(cash_series) > 0 else 0 + equity_val = _safe_float(equity_series.iloc[0]) if equity_series is not None and len(equity_series) > 0 else 0 + + shares = info.get("sharesOutstanding") or info.get("impliedSharesOutstanding") or 1 + beta = info.get("beta") or 1.0 + + # FCFF = EBIT(1-t) + D&A - CapEx - delta WC (approximate) + fcff = None + if ebit0 is not None: + fcff = ebit0 * (1 - tax_rate) + (dep0 or 0) - capx0 + + # FCFE = Net Income + D&A - CapEx - delta WC + Net Borrowing (approximate as NI + D&A - CapEx) + fcfe = None + if ni0 is not None: + fcfe = ni0 + (dep0 or 0) - capx0 + + # WACC components + rf = 0.045 # risk-free rate + erp = 0.055 # equity risk premium + cost_of_equity = rf + beta * erp + cost_of_debt = 0.05 # approximate + if total_debt and equity_val and (total_debt + equity_val) > 0: + debt_weight = total_debt / (total_debt + equity_val) + equity_weight = equity_val / (total_debt + equity_val) + else: + debt_weight, equity_weight = 0.2, 0.8 + + wacc = equity_weight * cost_of_equity + debt_weight * cost_of_debt * (1 - tax_rate) + + # Margins + fcff_margin = (fcff / r0 * 100) if fcff and r0 and r0 > 0 else None + fcfe_margin = (fcfe / r0 * 100) if fcfe and r0 and r0 > 0 else None + + return { + "fcff": fcff, "fcfe": fcfe, "fcff_margin": fcff_margin, "fcfe_margin": fcfe_margin, + "ebit": ebit0, "net_income": ni0, "revenue": r0, + "tax_rate": tax_rate * 100, "depreciation": dep0, "capex": capx0, + "total_debt": total_debt, "cash": cash_val, "equity": equity_val, + "shares": shares, "beta": beta, + "wacc": wacc * 100, "cost_of_equity": cost_of_equity * 100, "cost_of_debt": cost_of_debt * 100, + "debt_weight": debt_weight * 100, "equity_weight": equity_weight * 100, + } + except Exception: + return {} diff --git a/requirements.txt b/requirements.txt index cac7077..865476e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ python-dotenv>=1.0.0 yfinance>=0.2.40 yahooquery>=2.2.0 plotly>=5.18.0 +feedparser>=6.0.0 diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..9387fc2 --- /dev/null +++ b/run.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# 앱 실행 — 접속 주소는 http://localhost:8501 하나만 사용합니다. +cd "$(dirname "$0")" +streamlit run app.py --server.port 8501 diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/charts.py b/utils/charts.py new file mode 100644 index 0000000..7286417 --- /dev/null +++ b/utils/charts.py @@ -0,0 +1,140 @@ +""" +Plotly chart builders: Sankey, Radar, dark theme. +""" +try: + import plotly.graph_objects as go +except ImportError: + go = None + + +def _build_sankey_figure(data: dict) -> "go.Figure": + """Sankey: Revenue -> COGS + Gross Profit; Gross Profit -> OpEx + OpInc; OpInc -> Tax/Interest/Other + Net Income.""" + if go is None: + return None + rev, cogs, gp, opex, opinc, tax_other, ni = ( + data["revenue"], data["cogs"], data["gross_profit"], data["opex"], + data["operating_income"], data["tax_interest_other"], data["net_income"], + ) + if rev <= 0: + return None + # Format labels with dollar values + def _fmt(label, val): + if abs(val) >= 1e9: + return f"{label}
${val/1e9:.1f}B" + if abs(val) >= 1e6: + return f"{label}
${val/1e6:.0f}M" + return label + nodes = [ + _fmt("Revenue", rev), _fmt("Cost of Revenue", cogs), _fmt("Gross Profit", gp), + _fmt("Operating Exp.", opex), _fmt("Operating Inc.", opinc), + _fmt("Tax/Int./Other", tax_other), _fmt("Net Income", ni), + ] + node_colors = [ + "#3B82F6", # Revenue — blue + "#F87171", # COGS — red + "#34D399", # Gross Profit — green + "#FB923C", # OpEx — orange + "#60A5FA", # Operating Income — light blue + "#9CA3AF", # Tax/Interest — grey + "#10B981", # Net Income — bright green + ] + link_colors = [ + "rgba(248,113,113,0.3)", # Rev -> COGS (red flow) + "rgba(52,211,153,0.3)", # Rev -> GP (green flow) + "rgba(251,146,60,0.3)", # GP -> OpEx (orange flow) + "rgba(96,165,250,0.3)", # GP -> OpInc (blue flow) + "rgba(156,163,175,0.25)", # OpInc -> Tax (grey flow) + "rgba(16,185,129,0.35)", # OpInc -> NI (green flow) + ] + source = [0, 0, 2, 2, 4, 4] + target = [1, 2, 3, 4, 5, 6] + value = [max(0, float(v)) for v in [cogs, gp, opex, opinc, tax_other, ni]] + fig = go.Figure(data=[go.Sankey( + node=dict(label=nodes, color=node_colors, pad=20, thickness=24, + line=dict(color="rgba(255,255,255,0.1)", width=1)), + link=dict(source=source, target=target, value=value, color=link_colors), + )]) + fig.update_layout( + title=dict(text="Income Statement Flow", font=dict(size=14, color="#F3F4F6", family="Inter")), + height=420, margin=dict(t=45, b=15, l=10, r=10), + font=dict(size=12, color="#D1D5DB", family="Inter"), + paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", + ) + return fig + + +def _build_radar_common(theta_list, r_list, title_text="Financial Health Radar") -> "go.Figure": + """Shared radar chart builder with Soft Navy theme.""" + if go is None: + return None + theta = theta_list + [theta_list[0]] + r = r_list + [r_list[0]] + fig = go.Figure() + # Add a "benchmark 50" ring for reference + fig.add_trace(go.Scatterpolar( + r=[50] * (len(theta_list) + 1), theta=theta, + fill="toself", fillcolor="rgba(255,255,255,0.02)", + line=dict(color="rgba(255,255,255,0.1)", width=1, dash="dot"), + name="Avg (50)", hoverinfo="skip", + )) + fig.add_trace(go.Scatterpolar( + r=r, theta=theta, fill="toself", + fillcolor="rgba(59, 130, 246, 0.2)", + line=dict(color="#60A5FA", width=2.5), + marker=dict(size=6, color="#60A5FA", symbol="circle"), + name="Score", + )) + fig.update_layout( + polar=dict( + bgcolor="rgba(0,0,0,0)", + radialaxis=dict(visible=True, range=[0, 100], tickvals=[20, 40, 60, 80], + tickfont=dict(size=9, color="#4B5563", family="JetBrains Mono"), + gridcolor="rgba(255,255,255,0.06)", linecolor="rgba(255,255,255,0.06)"), + angularaxis=dict(tickfont=dict(size=11, color="#D1D5DB", family="Inter"), + gridcolor="rgba(255,255,255,0.06)", linecolor="rgba(255,255,255,0.08)"), + ), + title=dict(text=title_text, font=dict(size=14, color="#F3F4F6", family="Inter")), + height=420, showlegend=False, + paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", + margin=dict(t=45, b=25, l=60, r=60), + ) + return fig + + +def _build_radar_figure_from_metrics(metrics: dict) -> "go.Figure": + """Build radar chart from precomputed metrics dict.""" + if not metrics or not metrics.get("r"): + return None + return _build_radar_common(metrics["theta"], metrics["r"], "Financial Health Radar (10-K Item 8)") + + +def _radar_norm(roe_pct, current_ratio, asset_turnover, equity_mult, rev_yoy_pct): + """Normalize 5 raw metrics to 0-100 for radar (same logic as get_radar_metrics_normalized).""" + def n_roe(x): return min(100, max(0, (x + 10) / 40 * 100)) if x is not None else 50 + def n_cr(x): return min(100, max(0, x / 3 * 100)) if x is not None else 50 + def n_at(x): return min(100, max(0, x * 50)) if x is not None else 50 + def n_em(x): return min(100, max(0, (x - 0.5) / 2.5 * 100)) if x is not None else 50 + def n_yoy(x): return min(100, max(0, (x + 20) / 50 * 100)) if x is not None else 50 + return [n_roe(roe_pct), n_cr(current_ratio), n_at(asset_turnover), n_em(equity_mult), n_yoy(rev_yoy_pct)] + + +def _build_radar_from_manual(roe_pct, current_ratio, asset_turnover, equity_mult, rev_yoy_pct) -> "go.Figure": + """Build radar chart from 5 manually entered ratios (fallback).""" + theta = ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"] + r = _radar_norm(roe_pct, current_ratio, asset_turnover, equity_mult, rev_yoy_pct) + return _build_radar_common(theta, r, "Financial Health Radar (Manual)") + + +def _apply_dark_theme(fig): + """Apply Soft Navy theme to Plotly figures.""" + fig.update_layout( + paper_bgcolor='rgba(0,0,0,0)', + plot_bgcolor='rgba(255,255,255,0.02)', + font=dict(color='#D1D5DB', family='Inter, JetBrains Mono, sans-serif', size=12), + xaxis=dict(gridcolor='rgba(255,255,255,0.05)', zerolinecolor='rgba(255,255,255,0.08)', tickfont=dict(family='JetBrains Mono', size=11)), + yaxis=dict(gridcolor='rgba(255,255,255,0.05)', zerolinecolor='rgba(255,255,255,0.08)', tickfont=dict(family='JetBrains Mono', size=11)), + legend=dict(bgcolor='rgba(0,0,0,0)', bordercolor='rgba(255,255,255,0.06)', font=dict(size=11)), + title_font=dict(color='#F3F4F6', size=14), + margin=dict(l=40, r=20, t=40, b=40), + ) + return fig diff --git a/utils/dcf.py b/utils/dcf.py new file mode 100644 index 0000000..84ea4f4 --- /dev/null +++ b/utils/dcf.py @@ -0,0 +1,75 @@ +""" +DCF valuation models and Damodaran WACC mapping. +""" +from config.constants import DAMODARAN_WACC + + +def dcf_intrinsic_value(fcf: float, wacc: float, terminal_growth: float, fcf_growth: float, years: int = 5) -> float: + """5-year DCF: project FCF with fcf_growth, then terminal value; discount at WACC. Returns enterprise value. Robust: avoids div by zero.""" + if fcf is None or fcf <= 0: + return 0.0 + if wacc <= terminal_growth or wacc <= 0: + return 0.0 + pv = 0.0 + fcft = float(fcf) + for t in range(1, years + 1): + pv += fcft / ((1 + wacc) ** t) + fcft *= (1 + fcf_growth) + terminal_fcf = fcft + tv = terminal_fcf * (1 + terminal_growth) / (wacc - terminal_growth) + pv += tv / ((1 + wacc) ** years) + return pv + + +def dcf_10y_2stage(fcf: float, wacc: float, term_growth: float, fcf_growth: float) -> float: + """10-Year 2-Stage DCF. Stage 1 (Y1-5): FCF grows at fcf_growth. Stage 2 (Y6-10): growth linearly fades from fcf_growth to term_growth by Y10. TV at Y10; discount all to PV.""" + if fcf is None or fcf <= 0: + return 0.0 + if wacc <= term_growth or wacc <= 0: + return 0.0 + pv = 0.0 + fcft = float(fcf) + for t in range(1, 6): + pv += fcft / ((1 + wacc) ** t) + fcft *= (1 + fcf_growth) + for t in range(6, 11): + fade = (t - 6) / 4.0 + g_t = fcf_growth + fade * (term_growth - fcf_growth) + fcft *= (1 + g_t) + pv += fcft / ((1 + wacc) ** t) + tv = fcft * (1 + term_growth) / (wacc - term_growth) + pv += tv / ((1 + wacc) ** 10) + return pv + + +def excel_style_dcf(fcf_base: float, wacc: float, term_growth: float, fcf_growth: float, total_debt: float, cash: float, shares: float) -> dict: + """10Y 2-Stage DCF: EV = PV(FCF Y1-10) + PV(TV); Equity = EV - Debt + Cash; Value per share = Equity / Shares.""" + ev = dcf_10y_2stage(fcf_base, wacc, term_growth, fcf_growth) + equity = ev - total_debt + cash + shares_safe = float(shares) if (shares is not None and float(shares) > 0) else None + value_per_share = (equity / shares_safe) if shares_safe else None + return {"ev": ev, "equity_value": equity, "value_per_share": value_per_share, "shares": shares_safe} + + +def _damodaran_wacc_for_sector(sector: str) -> float: + """Map yfinance sector string to closest Damodaran WACC. Default 8.0%.""" + if not sector: + return 8.0 + s = (sector or "").lower() + if "software" in s or "technology" in s or "internet" in s: + return DAMODARAN_WACC.get("Software", 8.5) + if "hardware" in s or "semiconductor" in s: + return DAMODARAN_WACC.get("Hardware", 9.0) + if "retail" in s or "consumer" in s or "cyclical" in s: + return DAMODARAN_WACC.get("Retail", 7.5) + if "financial" in s or "bank" in s or "insurance" in s: + return DAMODARAN_WACC.get("Financials", 8.0) + if "health" in s or "pharma" in s: + return DAMODARAN_WACC.get("Healthcare", 7.2) + if "industrial" in s: + return DAMODARAN_WACC.get("Industrial", 7.8) + if "energy" in s or "oil" in s: + return DAMODARAN_WACC.get("Energy", 8.2) + if "utilities" in s: + return DAMODARAN_WACC.get("Utilities", 6.5) + return 8.0 diff --git a/utils/formatting.py b/utils/formatting.py new file mode 100644 index 0000000..2212a41 --- /dev/null +++ b/utils/formatting.py @@ -0,0 +1,36 @@ +""" +Small formatting/type-safety helpers used across multiple modules. +""" +from typing import Optional + +import pandas as pd + + +def _safe_float(x) -> Optional[float]: + if x is None or (isinstance(x, float) and (x != x or pd.isna(x))): + return None + try: + return float(x) + except (TypeError, ValueError): + return None + + +def _format_shares_display(shares: float) -> str: + """Format share count for UI, e.g. 15.42B Shares or 1.2B Shares.""" + if shares is None or shares <= 0: + return "N/A" + s = float(shares) + if s >= 1e9: + return f"{s / 1e9:.2f}B Shares" + if s >= 1e6: + return f"{s / 1e6:.2f}M Shares" + if s >= 1e3: + return f"{s / 1e3:.2f}K Shares" + return f"{s:.0f} Shares" + + +def _na(x): + """Return N/A for None/NaN, else value (for display).""" + if x is None or (isinstance(x, float) and (pd.isna(x) or x != x)): + return "N/A" + return x diff --git a/utils/prefs.py b/utils/prefs.py new file mode 100644 index 0000000..0b3343a --- /dev/null +++ b/utils/prefs.py @@ -0,0 +1,43 @@ +""" +Local preferences: API keys, email, last ticker. Saved to .app_prefs.json. +""" +import json +from pathlib import Path + +_PREFS_PATH = Path(__file__).resolve().parent.parent / ".app_prefs.json" +_DATA_DIR = Path(__file__).resolve().parent.parent / "data" + + +def _load_prefs() -> dict: + """Load saved API keys and email from local file. Keys: google_api_key, sec_email.""" + try: + if _PREFS_PATH.exists(): + with open(_PREFS_PATH, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return {} + + +def _save_prefs(google_api_key: str, sec_email: str, last_ticker: str = None, last_company_options: list = None, last_company_symbols: list = None) -> None: + """Save API keys, email, and last selected company to local file.""" + try: + data = {} + if _PREFS_PATH.exists(): + try: + with open(_PREFS_PATH, "r", encoding="utf-8") as f: + data = json.load(f) + except Exception: + pass + data["google_api_key"] = (google_api_key or "").strip() + data["sec_email"] = (sec_email or "").strip() + if last_ticker is not None: + data["last_ticker"] = (last_ticker or "").strip() + if last_company_options is not None: + data["last_company_options"] = list(last_company_options) if last_company_options else [] + if last_company_symbols is not None: + data["last_company_symbols"] = list(last_company_symbols) if last_company_symbols else [] + with open(_PREFS_PATH, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + except Exception: + pass diff --git a/utils/ticker.py b/utils/ticker.py new file mode 100644 index 0000000..83909d6 --- /dev/null +++ b/utils/ticker.py @@ -0,0 +1,37 @@ +""" +Ticker formatting and market inference utilities. +""" +from config.constants import MARKET_OPTIONS + + +def get_global_ticker(ticker: str, market: str) -> str: + """Format ticker for Yahoo Finance by market. US: as-is. South Korea: .KS or .KQ. Japan: .T. UK: .L. If ticker already has suffix, return as-is.""" + if not (ticker or "").strip(): + return (ticker or "").strip() + t = (ticker or "").strip() + if t.upper().endswith((".KS", ".KQ", ".T", ".L")): + return t + m = (market or "").strip() + if "US" in m or not m: + return t + if "Korea" in m or "KOSPI" in m or "KOSDAQ" in m: + return t + ".KS" + if "Japan" in m or "Nikkei" in m: + return t + ".T" + if "UK" in m or "LSE" in m: + return t + ".L" + return t + + +def infer_market_from_ticker(ticker: str) -> str: + """Infer market label from ticker suffix (for Deep-Dive routing when no Market selector).""" + if not (ticker or "").strip(): + return MARKET_OPTIONS[0] + t = (ticker or "").strip().upper() + if t.endswith(".KS") or t.endswith(".KQ"): + return "South Korea (KOSPI/KOSDAQ)" + if t.endswith(".T"): + return "Japan (Nikkei)" + if t.endswith(".L"): + return "UK (LSE)" + return "US (S&P/Dow/Nasdaq)" diff --git a/utils/ui_helpers.py b/utils/ui_helpers.py new file mode 100644 index 0000000..4e7ae2b --- /dev/null +++ b/utils/ui_helpers.py @@ -0,0 +1,90 @@ +""" +UI helper functions for rendering analyst consensus, sensitivity tables, etc. +""" +import pandas as pd +import streamlit as st + +from utils.dcf import excel_style_dcf + + +def _render_analyst_consensus(ticker: str): + """Render analyst consensus rating with visual badge and target prices.""" + from data.valuation import get_analyst_consensus + consensus = get_analyst_consensus(ticker) + if not consensus: + st.caption("Analyst consensus data not available.") + return + + rec = (consensus.get("recommendationKey") or "N/A").upper() + target_mean = consensus.get("targetMeanPrice") + target_high = consensus.get("targetHighPrice") + target_low = consensus.get("targetLowPrice") + num_analysts = consensus.get("numberOfAnalystOpinions", "N/A") + current = consensus.get("currentPrice") + + # Rating badge colors + if rec in ("BUY", "STRONG_BUY", "STRONG BUY"): + badge_bg = "rgba(52, 211, 153, 0.15)" + badge_border = "rgba(52, 211, 153, 0.4)" + badge_color = "#34D399" + elif rec in ("SELL", "STRONG_SELL", "STRONG SELL"): + badge_bg = "rgba(248, 113, 113, 0.15)" + badge_border = "rgba(248, 113, 113, 0.4)" + badge_color = "#F87171" + else: + badge_bg = "rgba(251, 191, 36, 0.15)" + badge_border = "rgba(251, 191, 36, 0.4)" + badge_color = "#FBBF24" + rec_display = rec.replace("_", " ") + + upside = "" + if target_mean and current and current > 0: + upside_pct = (target_mean - current) / current * 100 + upside_color = "#34D399" if upside_pct > 0 else "#F87171" + upside = f'{upside_pct:+.1f}% implied' + + if target_mean: + st.markdown(f""" +
+
+
ANALYST RATING
+ {rec_display} + {upside} +
+
+
+
TARGET (MEAN)
+
${target_mean:,.2f}
+
+
+
HIGH
+
${target_high:,.2f}
+
+
+
LOW
+
${target_low:,.2f}
+
+
+
ANALYSTS
+
{num_analysts}
+
+
+
+ """, unsafe_allow_html=True) + else: + st.caption("Analyst targets not available.") + + +def _render_sensitivity_table(fcf: float, total_debt: float, cash: float, shares: float): + """Render DCF sensitivity table: WACC vs Terminal Growth Rate.""" + wacc_range = [0.065, 0.070, 0.075, 0.080, 0.085, 0.090, 0.095, 0.100] + tgr_range = [0.020, 0.025, 0.030, 0.035, 0.040] + rows = [] + for tgr in tgr_range: + row = {"TGR": f"{tgr*100:.1f}%"} + for w in wacc_range: + res = excel_style_dcf(fcf, w, tgr, 0.10, total_debt, cash, shares) + val = res.get("value_per_share", 0) + row[f"{w*100:.1f}%"] = f"${val:,.0f}" if val and val > 0 else "N/A" + rows.append(row) + return pd.DataFrame(rows).set_index("TGR") diff --git a/views/__init__.py b/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/views/sidebar.py b/views/sidebar.py new file mode 100644 index 0000000..cbb0c61 --- /dev/null +++ b/views/sidebar.py @@ -0,0 +1,142 @@ +import os +import streamlit as st +from utils.prefs import _load_prefs, _save_prefs, _PREFS_PATH +from utils.ticker import infer_market_from_ticker + +try: + from yahooquery import search as yq_search +except ImportError: + yq_search = None + + +def render_sidebar(): + with st.sidebar: + st.markdown(""" +
+ ATLAS + TERMINAL +
+""", unsafe_allow_html=True) + st.markdown('
SETTINGS
', unsafe_allow_html=True) + _prefs = _load_prefs() + # Restore last selected company on refresh (session_state is empty after reload) + if _prefs.get("last_ticker") and not st.session_state.get("company_search_options"): + _lt = _prefs["last_ticker"] + _opts = _prefs.get("last_company_options") or [] + _syms = _prefs.get("last_company_symbols") or [] + if not _opts and _lt: + _opts = [f"[Saved] {_lt}"] + _syms = [_lt] + st.session_state["ticker"] = _lt + st.session_state["company_search_options"] = _opts + st.session_state["company_search_symbols"] = _syms + _default_key = _prefs.get("google_api_key") or os.environ.get("GOOGLE_API_KEY", "") + _default_email = _prefs.get("sec_email") or os.environ.get("SEC_EDGAR_EMAIL", "") + google_api_key = st.text_input( + "Google API Key (Gemini)", + value=_default_key, + help="Required for Tab 1 (10-K insights).", + key="input_google_api_key", + ) + sec_email = st.text_input( + "SEC EDGAR Email", + value=_default_email, + help="Required for 10-K download.", + key="input_sec_email", + ) + remember_me = st.checkbox( + "Remember API key & email (save locally)", + value=bool(_prefs), + help="Store in .app_prefs.json in this project. Uncheck to clear and stop saving.", + key="remember_me", + ) + if remember_me and (google_api_key or sec_email): + _save_prefs(google_api_key, sec_email) + elif not remember_me and _PREFS_PATH.exists(): + # Clear only API keys in prefs; keep last_ticker so company selection persists on refresh + try: + _cur = _load_prefs() + _save_prefs("", "", last_ticker=_cur.get("last_ticker"), last_company_options=_cur.get("last_company_options"), last_company_symbols=_cur.get("last_company_symbols")) + except Exception: + pass + st.markdown('
COMPANY SEARCH
', unsafe_allow_html=True) + search_query = st.text_input( + "Search Company Name (e.g., Apple, 삼성, Mitsubishi)", + value=st.session_state.get("company_search_input", ""), + key="company_search_input", + placeholder="e.g. Apple, 삼성, Mitsubishi", + ) + if st.button("Search Company", key="search_company_btn"): + query = (search_query or "").strip() + if not query: + st.warning("Enter a company name to search.") + elif yq_search is None: + st.warning("yahooquery is not installed; search is unavailable.") + else: + try: + raw_results = yq_search(query) + if not isinstance(raw_results, dict): + raw_results = {} + quotes = raw_results.get("quotes", []) or [] + skip_types = ("INDEX", "MUTUALFUND") + quotes = [ + q for q in quotes + if q.get("symbol") and q.get("shortname") + and (q.get("quoteType") or "EQUITY") not in skip_types + ] + if not quotes: + st.session_state["company_search_options"] = [] + st.session_state["company_search_symbols"] = [] + st.warning("No valid equities found. Try typing the English name (e.g., 'Samsung' instead of '삼성').") + else: + options = [] + symbols = [] + for q in quotes[:50]: + sym = (q.get("symbol") or "").strip() + options.append(f"[{q.get('exchange', 'N/A')}] {q.get('symbol')} - {q.get('shortname', 'Unknown')}") + symbols.append(sym) + st.session_state["company_search_options"] = options + st.session_state["company_search_symbols"] = symbols + st.session_state["ticker"] = symbols[0] + st.success(f"Found {len(options)} result(s). Select below.") + except Exception: + st.warning("No valid equities found. Try typing the English name (e.g., 'Samsung' instead of '삼성').") + st.session_state["company_search_options"] = [] + st.session_state["company_search_symbols"] = [] + + search_options = st.session_state.get("company_search_options") or [] + search_symbols = st.session_state.get("company_search_symbols") or [] + placeholder = "— Click the search button above —" + options_for_select = [placeholder] if not search_options else search_options + current_ticker = st.session_state.get("ticker", "NVDA") + default_idx = 0 + if search_symbols and current_ticker: + for i, sym in enumerate(search_symbols): + if sym == current_ticker: + default_idx = i + break + selected_option = st.selectbox( + "Select company (ticker - name)", + options=options_for_select, + index=0 if not search_options else min(default_idx, len(search_options) - 1), + key="company_select", + ) + if search_options and selected_option and selected_option != placeholder and " - " in selected_option: + first_part = selected_option.split(" - ", 1)[0].strip() + sym = first_part.split("]", 1)[-1].strip() if "]" in first_part else first_part + st.session_state["ticker"] = sym + ticker = st.session_state.get("ticker") or (search_symbols[0] if search_symbols else "NVDA") + st.session_state["google_api_key"] = google_api_key + st.session_state["sec_email"] = sec_email + st.session_state["ticker"] = ticker + st.session_state["market"] = infer_market_from_ticker(ticker) + # Persist selected company so it survives page refresh + _save_prefs( + google_api_key if st.session_state.get("remember_me") else "", + sec_email if st.session_state.get("remember_me") else "", + last_ticker=ticker, + last_company_options=search_options, + last_company_symbols=search_symbols, + ) + st.caption("Search by name (any language), then select. Ticker suffix is set automatically.") + return ticker diff --git a/views/tab1_ai.py b/views/tab1_ai.py new file mode 100644 index 0000000..b3423d6 --- /dev/null +++ b/views/tab1_ai.py @@ -0,0 +1,126 @@ +"""Tab 1 — Deep-Dive AI Analysis: Management Strategy & Risk Factors buttons, +display of saved results.""" + +import streamlit as st +from data.sec_downloader import get_10k_sections +from data.fundamentals import get_sector_industry +from ai.gemini_core import _gemini_forensic_audit +from ai.gemini_sec import get_gemini_item7_strategy_stream, get_gemini_item1a_risks_stream + + +def render_tab1_ai_analysis(ticker, quant_ticker, market): + """Render the Deep-Dive Analysis (AI) section of Tab 1.""" + st.markdown("---") + st.markdown("#### 🔍 Deep-Dive Analysis (AI)") + st.caption("10-K sections are cached in **data/**; repeat runs use cache for instant AI analysis. First run may take 20–60 s to fetch 10-K; Gemini then streams in ~5–10 s.") + if not ticker: + st.caption("Enter a ticker in the sidebar to enable analysis.") + else: + api_ok = bool(st.session_state.get("google_api_key")) + email_ok = bool(st.session_state.get("sec_email")) + err_msg = [] + if not api_ok: + err_msg.append("Google API Key") + if not email_ok: + err_msg.append("SEC EDGAR Email") + if err_msg: + st.caption(f"Set **{' and '.join(err_msg)}** in the sidebar to run analysis.") + col_a, col_b = st.columns(2) + is_us = market and "US" in market + is_korea = market and ("Korea" in market or "KOSPI" in market or "KOSDAQ" in market) + is_japan_uk = market and ("Japan" in market or "Nikkei" in market or "UK" in market or "LSE" in market) + # --- Button A: Management Strategy --- + with col_a: + if st.button("Analyze Management Strategy (MD&A)", key="run_mda_strategy"): + if is_korea: + st.warning("DART API integration for Korean MD&A is currently under construction. Please check back in Phase 2.") + elif is_japan_uk: + st.warning("EDINET/LSE document parsing is currently under development.") + elif not api_ok or not email_ok: + st.error("Set API Key and SEC Email in the sidebar.") + else: + try: + with st.status("Loading 10-K (cache or download)...", expanded=True) as status: + sections, _ = get_10k_sections(ticker, st.session_state["sec_email"]) + si = get_sector_industry(quant_ticker) + status.update(label="10-K loaded. Calling Gemini…", state="running") + + # Stream OUTSIDE the status box so user sees text as it arrives + st.markdown("### Management Strategy (Item 7)") + st.caption("Streaming from Gemini (first words in ~5–10 sec, then flows in real time).") + stream_gen = get_gemini_item7_strategy_stream( + st.session_state["google_api_key"], + sections.get("item7") or "", + ticker, + si.get("sector") or "N/A", + si.get("industry") or "N/A", + ) + # write_stream returns the full concatenated string after it finishes streaming + full_response = st.write_stream(stream_gen) + + st.session_state["mda_strategy_result"] = full_response + st.session_state["mda_strategy_ticker"] = ticker + st.session_state["mda_strategy_error"] = None + except Exception as e: + st.session_state["mda_strategy_error"] = str(e) + st.error(f"Strategy analysis failed: {str(e)}") + # --- Button B: Risk Factors & Forensic --- + with col_b: + if st.button("Analyze Risk Factors (Item 1A)", key="run_mda_risk"): + if is_korea: + st.warning("DART API integration for Korean MD&A is currently under construction. Please check back in Phase 2.") + elif is_japan_uk: + st.warning("EDINET/LSE document parsing is currently under development.") + elif not api_ok or not email_ok: + st.error("Set API Key and SEC Email in the sidebar.") + else: + try: + with st.status("Loading 10-K (cache or download)...", expanded=True) as status: + sections, _ = get_10k_sections(ticker, st.session_state["sec_email"]) + status.update(label="10-K loaded. Running forensic audit…", state="running") + + # Run forensic silently IN THE BACKGROUND first + forensic = _gemini_forensic_audit( + st.session_state["google_api_key"], + sections.get("item3") or "", + sections.get("item9a") or "", + ticker, + ) + status.update(label="Done.", state="complete") + # Stream the risk factors OUTSIDE the status box + st.markdown("### Risk Factors (Item 1A)") + st.caption("Streaming from Gemini (first words in ~5–10 sec, then flows in real time).") + stream_gen = get_gemini_item1a_risks_stream( + st.session_state["google_api_key"], + sections.get("item1a") or "", + ticker, + ) + risk_response = st.write_stream(stream_gen) + + # Combine both for the final result + final_out = risk_response + if forensic and forensic.strip(): + st.markdown("### Forensic Audit (Item 3 & 9A)") + st.markdown(forensic.strip()) + final_out += f"\n\n---\n\n### Forensic Audit (Item 3 & 9A)\n\n{forensic.strip()}" + + st.session_state["mda_risk_result"] = final_out + st.session_state["mda_risk_ticker"] = ticker + st.session_state["mda_risk_error"] = None + except Exception as e: + st.session_state["mda_risk_error"] = str(e) + st.error(f"Risk analysis failed: {str(e)}") + # --- Display Saved Results if User Switches Tabs --- + st.markdown("---") + if st.session_state.get("mda_strategy_ticker") == ticker: + if st.session_state.get("mda_strategy_error"): + st.error("Strategy Error: " + st.session_state["mda_strategy_error"]) + elif st.session_state.get("mda_strategy_result"): + with st.expander("View Previous Strategy Analysis", expanded=True): + st.markdown(st.session_state["mda_strategy_result"]) + if st.session_state.get("mda_risk_ticker") == ticker: + if st.session_state.get("mda_risk_error"): + st.error("Risk Error: " + st.session_state["mda_risk_error"]) + elif st.session_state.get("mda_risk_result"): + with st.expander("View Previous Risk & Forensic Analysis", expanded=True): + st.markdown(st.session_state["mda_risk_result"]) diff --git a/views/tab1_filings.py b/views/tab1_filings.py new file mode 100644 index 0000000..7de6ae4 --- /dev/null +++ b/views/tab1_filings.py @@ -0,0 +1,95 @@ +"""Tab 1 — SEC/DART Original Filing Viewer (Native HTML rendering).""" + +import streamlit as st +import streamlit.components.v1 as components_v1 + +from data.sec_fetcher import fetch_sec_filing_html, _wrap_edgar_html_for_iframe + + +def render_tab1_filings(ticker, market): + """Render the SEC / DART original filing viewer section of Tab 1.""" + st.markdown("---") + st.markdown("#### 공시 원본 뷰어 (SEC Filing / DART)") + + is_us_filing = market and "US" in market + is_kr_filing = market and ("Korea" in market or "KOSPI" in market or "KOSDAQ" in market) + + if is_us_filing: + # ── [4] Filing type selector ── + _filing_col1, _filing_col2, _filing_col3 = st.columns([1.5, 2, 1.5]) + with _filing_col1: + _sec_filing_type = st.selectbox( + "SEC Filing Type", + ["10-K", "10-Q", "8-K", "20-F", "6-K"], + index=0, + key="sec_filing_type_select", + ) + with _filing_col2: + st.caption("") # spacer + _edgar_search_url = f"https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK={ticker}&type={_sec_filing_type}&dateb=&owner=include&count=10" + st.markdown( + f'↗ SEC EDGAR에서 {_sec_filing_type} 검색', + unsafe_allow_html=True, + ) + with _filing_col3: + _fetch_btn = st.button(f"📄 {_sec_filing_type} 원본 가져오기", key="fetch_sec_filing_btn") + + # ── [5] Fetch with selected filing type ── + if _fetch_btn or st.session_state.get("_last_sec_filing_html"): + if _fetch_btn: + with st.spinner(f"EDGAR에서 {ticker} {_sec_filing_type} 원본 HTML을 가져오는 중..."): + _result = fetch_sec_filing_html(ticker, _sec_filing_type) + st.session_state["_last_sec_filing_html"] = _result.get("html") + st.session_state["_last_sec_filing_error"] = _result.get("error") + st.session_state["_last_sec_filing_source"] = _result.get("source") + st.session_state["_last_sec_filing_url"] = _result.get("doc_url") + st.session_state["_last_sec_filing_type"] = _sec_filing_type + + _raw_html = st.session_state.get("_last_sec_filing_html") + _fetch_error = st.session_state.get("_last_sec_filing_error") + _html_source = st.session_state.get("_last_sec_filing_source") + _doc_url = st.session_state.get("_last_sec_filing_url") + + # ── [3] Show errors explicitly — never silently swallow ── + if _fetch_error: + st.error(f"SEC API Error: {_fetch_error}") + if _doc_url: + st.code(_doc_url, language="text") + + if _raw_html: + _doc_size_mb = len(_raw_html.encode("utf-8")) / 1e6 + _src_label = "디스크 캐시" if _html_source == "cache" else "EDGAR API" + st.caption(f"원본 HTML 렌더링 · {_doc_size_mb:.1f} MB · 출처: {_src_label}") + _wrapped = _wrap_edgar_html_for_iframe(_raw_html, ticker) + components_v1.html(_wrapped, height=900, scrolling=True) + if _doc_size_mb > 5: + st.caption(f"⚠ 문서가 큽니다({_doc_size_mb:.1f} MB). 느릴 경우 위 EDGAR 링크에서 원본 페이지를 여세요.") + else: + st.info(f"위 버튼을 클릭하면 {ticker}의 최신 {_sec_filing_type} 원본 문서를 SEC EDGAR에서 가져옵니다.") + + elif is_kr_filing: + # ── [6] Korean DART direct links ── + st.markdown("##### 🇰🇷 DART 공시 원본") + _dart_code = ticker.replace(".KS", "").replace(".KQ", "").strip() + _dart_col1, _dart_col2 = st.columns(2) + with _dart_col1: + _dart_company_url = f"https://dart.fss.or.kr/dsab001/main.do?autoSearch=true&textCrpNm={_dart_code}" + st.markdown( + f'' + f'📋 DART 전체 공시 보기 ({_dart_code})', + unsafe_allow_html=True, + ) + with _dart_col2: + _dart_annual_url = f"https://dart.fss.or.kr/dsab001/main.do?autoSearch=true&textCrpNm={_dart_code}&rghtBbstp=L" + st.markdown( + f'' + f'📊 DART 사업보고서 바로가기', + unsafe_allow_html=True, + ) + st.caption("DART 전자공시시스템에서 사업보고서, 분기보고서, 주요사항보고서 등 원본을 열람할 수 있습니다.") + else: + st.caption("공시 뷰어: US 종목(SEC EDGAR) 또는 한국 종목(DART)을 선택하세요.") diff --git a/views/tab1_quant.py b/views/tab1_quant.py new file mode 100644 index 0000000..ff6ff79 --- /dev/null +++ b/views/tab1_quant.py @@ -0,0 +1,209 @@ +"""Tab 1 — Financial Health (Tables & Charts): Sankey, Radar, F-Score, +Altman Z, red flags, sector metrics, YoY changes, quarterly momentum/ratios.""" + +import pandas as pd +import streamlit as st +from data.sec_downloader import get_10k_sections +from data.ratios import get_dupont_altman_redflags_yoy, get_quarterly_momentum, get_quarterly_ratio_changes +from data.scores import ( + get_income_statement_sankey_data, get_radar_metrics_normalized, _build_radar_figure, + get_piotroski_fscore, get_sector_specific_metrics, +) +from data.scores_ai import sankey_data_from_ai, piotroski_from_ai, radar_metrics_from_ai +from ai.gemini_sec import get_sec_financials_llm +from utils.charts import ( + _build_sankey_figure, _build_radar_figure_from_metrics, + _build_radar_from_manual, _apply_dark_theme, +) +try: + from yahooquery import Ticker as YQTicker +except ImportError: + YQTicker = None + + +def _style_change_column(df: pd.DataFrame): + """Green for improvement (+), red for decline (-) in Change column.""" + change_col = "Change (%)" if "Change (%)" in df.columns else "Change" + if change_col not in df.columns or df.empty: + return df.style + def _cell_style(v): + if v is None or (isinstance(v, float) and pd.isna(v)): + return "" + s = str(v).strip() + if s == "—": + return "" + if s.startswith("+") or "↑" in s: + return "background-color: #d4edda; color: #155724" + if s.startswith("-") or "↓" in s: + return "background-color: #f8d7da; color: #721c24" + return "" + return df.style.apply(lambda col: [_cell_style(v) for v in col], subset=[change_col]) + + +def render_tab1_quantitative(ticker, quant_ticker, market, sector, industry, google_api_key, sec_email): + """Render the Financial Health section of Tab 1.""" + ai_data = {} + if market and "US" in market and google_api_key and sec_email: + with st.spinner("SEC 10-K 원본에서 재무제표 데이터를 해독하여 그래프를 생성 중입니다... (약 30~60초 소요)"): + sections, _ = get_10k_sections(ticker, sec_email) + item8 = (sections or {}).get("item8") or "" + if item8.strip(): + ai_data = get_sec_financials_llm(google_api_key, item8, ticker) + q = get_dupont_altman_redflags_yoy(quant_ticker) + dupont_df = (q or {}).get("dupont") if q else None + if q or ai_data: + st.markdown("---") + st.markdown("#### 📊 Financial Health (Tables & Charts)") + c1, c2 = st.columns(2) + with c1: + if ai_data and ai_data.get("current_yr"): + sankey_data = sankey_data_from_ai(ai_data) + else: + sankey_data = get_income_statement_sankey_data(quant_ticker) + if sankey_data.get("revenue", 0) > 0: + fig_sankey = _build_sankey_figure(sankey_data) + if fig_sankey is not None: + _apply_dark_theme(fig_sankey) + st.plotly_chart(fig_sankey, use_container_width=True) + else: + st.caption("Income Statement flow: data not available.") + with c2: + if ai_data and ai_data.get("current_yr"): + radar_metrics = radar_metrics_from_ai(ai_data) + fig_radar = _build_radar_figure_from_metrics(radar_metrics) if radar_metrics else None + else: + fig_radar = _build_radar_figure(quant_ticker) + if fig_radar is not None: + _apply_dark_theme(fig_radar) + st.plotly_chart(fig_radar, use_container_width=True) + else: + st.caption("Financial radar: need 2+ years of data.") + with st.expander("Manual Data Entry (Radar Chart Fallback)", expanded=False): + st.caption("Enter 5 key ratios to plot a custom radar. ROE %, Current Ratio, Asset Turnover, Equity Mult., Revenue YoY %.") + roe_man = st.number_input("ROE %", value=15.0, min_value=-50.0, max_value=100.0, step=1.0, key="radar_roe") + cr_man = st.number_input("Current Ratio", value=1.5, min_value=0.0, max_value=10.0, step=0.1, key="radar_cr") + at_man = st.number_input("Asset Turnover", value=0.8, min_value=0.0, max_value=5.0, step=0.1, key="radar_at") + em_man = st.number_input("Equity Mult.", value=2.0, min_value=0.5, max_value=10.0, step=0.1, key="radar_em") + yoy_man = st.number_input("Revenue YoY %", value=10.0, min_value=-50.0, max_value=200.0, step=1.0, key="radar_yoy") + if st.button("Plot Radar", key="radar_plot_btn"): + fig_man = _build_radar_from_manual(roe_man, cr_man, at_man, em_man, yoy_man) + if fig_man is not None: + st.session_state["radar_manual_fig"] = fig_man + if st.session_state.get("radar_manual_fig") is not None: + _apply_dark_theme(st.session_state["radar_manual_fig"]) + st.plotly_chart(st.session_state["radar_manual_fig"], use_container_width=True) + with st.expander("Debug: Raw YahooQuery Data", expanded=False): + if YQTicker and quant_ticker: + try: + yq_ticker = YQTicker(quant_ticker.upper()) + inc_raw = yq_ticker.income_statement(trailing=False) + bal_raw = yq_ticker.balance_sheet(trailing=False) + if inc_raw is not None and not inc_raw.empty: + st.caption("Income statement (last 2 periods) — check column names for mapping.") + st.dataframe(inc_raw.tail(2), use_container_width=True, hide_index=True) + else: + st.caption("Income statement: no data.") + if bal_raw is not None and not bal_raw.empty: + st.caption("Balance sheet (last 2 periods) — check column names for mapping.") + st.dataframe(bal_raw.tail(2), use_container_width=True, hide_index=True) + else: + st.caption("Balance sheet: no data.") + except Exception as e: + st.error(f"YahooQuery debug failed: {e}") + else: + st.caption("YahooQuery not available or no ticker selected.") + if ai_data and ai_data.get("current_yr"): + piot = piotroski_from_ai(ai_data) + else: + piot = get_piotroski_fscore(quant_ticker) + st.markdown("**Piotroski F-Score (9-point checklist)**") + score = piot.get("score", 0) + legend = "**Score 8–9: Excellent** · 4–7: Average · 0–3: High Risk" + st.metric("F-Score", f"{score} / 9", legend) + if ai_data and ai_data.get("current_yr"): + st.caption("*(from SEC 10-K Item 8)*") + elif piot.get("used_ttm"): + st.caption("*(Estimated via TTM Data)*") + st.caption("✅ = Good (passes criterion). ❌ = Fails criterion.") + criteria = piot.get("criteria", []) + if criteria: + cols = st.columns(3) + for i, (label, passed) in enumerate(criteria): + with cols[i % 3]: + st.caption(("✅ " if passed else "❌ ") + label) + az = (q or {}).get("altman_z") + if az is not None: + st.caption(f"**Altman Z-Score:** {az} (Safe > 2.99 · Grey 1.81–2.99 · Distress < 1.81)") + red_flags = (q or {}).get("red_flags") or [] + if red_flags: + for rf in red_flags: + val = rf.get("value") + val_str = "N/A" if (val is None or (isinstance(val, float) and (pd.isna(val) or val != val))) else val + st.warning(f"**{rf.get('metric')}:** {val_str} (threshold: {rf.get('threshold')})") + elif dupont_df is not None and not dupont_df.empty: + st.success("No red flags (Current Ratio ≥ 1.0, Interest Coverage ≥ 1.5).") + sector_metrics = get_sector_specific_metrics(quant_ticker, sector) if quant_ticker else {} + if sector_metrics: + st.markdown("**Sector-specific metrics**") + cols = st.columns(min(len(sector_metrics), 4)) + for i, (k, v) in enumerate(sector_metrics.items()): + with cols[i % len(cols)]: + disp = f"{v}" if v is not None else "N/A" + st.metric(k, disp, None) + yoy_list = (q or {}).get("yoy") or [] + if yoy_list: + st.markdown("**YoY ratio changes**") + rows_yoy = [] + for item in yoy_list: + cur = item.get("Latest") + if cur is not None and isinstance(cur, (int, float)): + cur = round(cur, 2) + chg_pp = item.get("YoY (pp)") + chg_pct = item.get("YoY %") + if chg_pp is not None: + chg_str = f"{chg_pp:+.1f}%" + elif chg_pct is not None: + chg_str = f"{chg_pct:+.1f}%" + else: + chg_str = "—" + status = "↑" if (chg_pp is not None and chg_pp > 0) or (chg_pct is not None and chg_pct > 0) else ("↓" if (chg_pp is not None and chg_pp < 0) or (chg_pct is not None and chg_pct < 0) else "—") + cur_disp = f"{cur:.2f}" if isinstance(cur, (int, float)) else ("—" if cur is None else str(cur)) + rows_yoy.append({"Metric": item.get("Ratio"), "Current Value": cur_disp, "Change (%)": chg_str, "Status": status}) + if rows_yoy: + df_yoy = pd.DataFrame(rows_yoy) + st.dataframe(_style_change_column(df_yoy), use_container_width=True, hide_index=True) + st.markdown("**Quarter ratio changes**") + qmom = get_quarterly_momentum(quant_ticker) + qoq_rows = get_quarterly_ratio_changes(quant_ticker) + qoq_r, qoq_n = qmom.get("qoq_revenue_pct"), qmom.get("qoq_ni_pct") + build = [] + if qoq_r is not None: + build.append({"Metric": "Revenue", "Current Value": "—", "Change (%)": f"{qoq_r:+.1f}%", "Status": "↑" if qoq_r > 0 else "↓"}) + if qoq_n is not None: + build.append({"Metric": "Net Income", "Current Value": "—", "Change (%)": f"{qoq_n:+.1f}%", "Status": "↑" if qoq_n > 0 else "↓"}) + for r in qoq_rows: + r_copy = dict(r) + if "Current Value" in r_copy: + v = r_copy["Current Value"] + if isinstance(v, (int, float)): + r_copy["Current Value"] = f"{round(v, 2):.2f}" + elif v is None: + r_copy["Current Value"] = "—" + else: + r_copy["Current Value"] = str(v) + if "Change" in r_copy and "Change (%)" not in r_copy: + r_copy["Change (%)"] = r_copy.pop("Change", "—") + if "Trend" in r_copy: + r_copy["Status"] = r_copy.pop("Trend", "—") + build.append(r_copy) + if build: + df_q = pd.DataFrame(build) + if "Change" in df_q.columns and "Change (%)" not in df_q.columns: + df_q = df_q.rename(columns={"Change": "Change (%)"}) + if "Trend" in df_q.columns: + df_q = df_q.rename(columns={"Trend": "Status"}) + st.dataframe(_style_change_column(df_q), use_container_width=True, hide_index=True) + elif not qmom.get("df") or qmom["df"].empty: + st.caption("Quarterly data not available for this ticker.") + else: + st.info("Quantitative data not available for this ticker.") diff --git a/views/tab2_dcf.py b/views/tab2_dcf.py new file mode 100644 index 0000000..070ca18 --- /dev/null +++ b/views/tab2_dcf.py @@ -0,0 +1,239 @@ +import pandas as pd +import streamlit as st +from config.constants import MARKET_OPTIONS, DAMODARAN_ERP_PCT, DAMODARAN_RF_PCT +from utils.ticker import get_global_ticker +from utils.formatting import _format_shares_display +from utils.dcf import excel_style_dcf, _damodaran_wacc_for_sector +from utils.charts import _apply_dark_theme +from utils.ui_helpers import _render_analyst_consensus, _render_sensitivity_table +from data.fundamentals import get_sector_industry, get_5yr_financial_trend, get_dcf_inputs +from data.valuation import get_analyst_consensus, get_dcf_smart_defaults, get_fcff_fcfe_valuation +try: + import plotly.express as px +except ImportError: + px = None +try: + import yfinance as yf +except ImportError: + yf = None + + +def render_tab2(ticker): + market_t2 = st.session_state.get("market") or MARKET_OPTIONS[0] + quant_ticker_t2 = get_global_ticker(ticker, market_t2) if ticker else "" + st.subheader("5-Year Financial Trend & DCF Valuation") + if ticker: + si_t2 = get_sector_industry(quant_ticker_t2) + sector_t2 = (si_t2.get("sector") or "").lower() + is_financial = "financial" in sector_t2 or "bank" in sector_t2 or "insurance" in sector_t2 + else: + is_financial = False + df_trend = get_5yr_financial_trend(quant_ticker_t2) if quant_ticker_t2 else pd.DataFrame() + if not df_trend.empty and len(df_trend) >= 1: + st.markdown("#### Key metrics (YoY % change)") + latest = df_trend.iloc[0] + prev = df_trend.iloc[1] if len(df_trend) >= 2 else None + def _yoy_pct(cur, prev_val): + if prev_val is None or cur is None or prev_val == 0: + return None + return (cur - prev_val) / abs(prev_val) * 100 + rev_yoy = _yoy_pct(latest.get("Revenue"), prev.get("Revenue") if prev is not None else None) + ni_yoy = _yoy_pct(latest.get("Net Income"), prev.get("Net Income") if prev is not None else None) + om_prev = prev.get("Operating Margin %") if prev is not None else None + om_cur = latest.get("Operating Margin %") + om_yoy = (om_cur - om_prev) if (om_cur is not None and om_prev is not None) else None + fcf_yoy = _yoy_pct(latest.get("FCF"), prev.get("FCF") if prev is not None else None) + m1, m2, m3, m4 = st.columns(4) + rev_val = latest.get("Revenue") + m1.metric("Revenue (latest yr)", f"${rev_val/1e9:.2f}B" if rev_val and rev_val >= 1e9 else (f"${rev_val/1e6:.0f}M" if rev_val else "\u2014"), f"{rev_yoy:+.1f}% YoY" if rev_yoy is not None else None) + ni_val = latest.get("Net Income") + m2.metric("Net Income", f"${ni_val/1e9:.2f}B" if ni_val and abs(ni_val) >= 1e9 else (f"${ni_val/1e6:.0f}M" if ni_val is not None else "\u2014"), f"{ni_yoy:+.1f}% YoY" if ni_yoy is not None else None) + om_val = latest.get("Operating Margin %") + m3.metric("Operating Margin %", f"{om_val:.1f}%" if om_val is not None else "\u2014", f"{om_yoy:+.1f}pp YoY" if om_yoy is not None else None) + fcf_val = latest.get("FCF") + m4.metric("FCF", f"${fcf_val/1e9:.2f}B" if fcf_val and abs(fcf_val) >= 1e9 else (f"${fcf_val/1e6:.0f}M" if fcf_val is not None else "\u2014"), f"{fcf_yoy:+.1f}% YoY" if fcf_yoy is not None else None) + st.caption("FCF = Operating Cash Flow \u2212 Capital Expenditure." + (" For Financials, FCF/EBITDA are less relevant; see ROE/ROA in Tab 1 sector-specific metrics." if is_financial else "")) + if len(df_trend) >= 2 and px is not None: + st.markdown("#### 5-year trend: Revenue & FCF") + df_plot = df_trend.copy() + df_plot["Revenue_M"] = (df_plot["Revenue"] / 1e6).round(1) + df_plot["FCF_M"] = (df_plot["FCF"] / 1e6).round(1) + fig = px.line(df_plot, x="Year", y=["Revenue_M", "FCF_M"], title="Revenue & Free Cash Flow ($M)") + fig.update_layout(yaxis_title="$M", legend_title="", hovermode="x unified") + fig.update_traces(line=dict(width=2)) + _apply_dark_theme(fig) + st.plotly_chart(fig, use_container_width=True) + elif ticker: + st.caption("5-year trend not available for this ticker. DCF section below uses latest FCF from yfinance.") + st.markdown("---") + st.markdown("#### DCF valuation (Excel-style): inputs & 3-scenario output") + dcf_inputs = get_dcf_inputs(quant_ticker_t2) if quant_ticker_t2 else {"fcf": None, "total_debt": 0.0, "cash": 0.0, "shares": None} + fcf_fetched = dcf_inputs.get("fcf") + total_debt = float(dcf_inputs.get("total_debt") or 0.0) + cash = float(dcf_inputs.get("cash") or 0.0) + shares_fetched = dcf_inputs.get("shares") + # Base FCF + if fcf_fetched is None or fcf_fetched <= 0: + fcf = st.number_input("Base FCF (manual \u2014 only if yfinance missing)", value=0.0, min_value=-1e12, step=1e8, format="%.0f", key="dcf_fcf_manual") + else: + fcf = float(fcf_fetched) + st.caption(f"Base FCF (OCF \u2212 CapEx): **${fcf/1e9:.2f}B**" if abs(fcf) >= 1e9 else f"Base FCF (OCF \u2212 CapEx): **${fcf/1e6:.0f}M**") + # Shares: auto-fetched (fast_info -> info -> balance); manual only as last resort + if shares_fetched is not None and shares_fetched > 0: + shares = float(shares_fetched) + st.caption(f"Shares Outstanding: **{_format_shares_display(shares)}** (real-time, auto-fetched)") + else: + shares = st.number_input("Shares Outstanding (manual \u2014 only if all API sources failed)", value=1e9, min_value=1.0, step=1e7, format="%.0f", key="dcf_shares_manual") + # Total Debt & Cash: manual only when both API sources completely failed + if total_debt == 0 and cash == 0: + c1, c2 = st.columns(2) + with c1: + total_debt = st.number_input("Total Debt (manual \u2014 only if all sources failed)", value=0.0, min_value=0.0, step=1e8, format="%.0f", key="dcf_debt_manual") + with c2: + cash = st.number_input("Cash & Equivalents (manual \u2014 only if all sources failed)", value=0.0, min_value=0.0, step=1e8, format="%.0f", key="dcf_cash_manual") + else: + st.caption(f"Total Debt: **${total_debt/1e9:.2f}B**" if total_debt >= 1e9 else f"Total Debt: **${total_debt/1e6:.0f}M**" if total_debt >= 1e6 else f"Total Debt: **${total_debt:,.0f}**") + st.caption(f"Cash & Equivalents: **${cash/1e9:.2f}B**" if cash >= 1e9 else f"Cash & Equivalents: **${cash/1e6:.0f}M**" if cash >= 1e6 else f"Cash & Equivalents: **${cash:,.0f}**") + dcf_defaults = get_dcf_smart_defaults(quant_ticker_t2) if quant_ticker_t2 else {"wacc_pct": 10.0, "term_growth_pct": 2.5, "fcf_growth_pct": 8.0} + st.markdown("**Assumptions (sliders)**") + st.caption("\U0001f4a1 Slider defaults are auto-generated based on the company's Beta (CAPM) and revenue growth estimates.") + col1, col2, col3 = st.columns(3) + with col1: + wacc = st.slider("WACC (Discount Rate) %", 4.0, 20.0, float(dcf_defaults["wacc_pct"]), 0.5, key="dcf_wacc") / 100.0 + with col2: + term_growth = st.slider("Terminal Growth Rate %", -2.0, 6.0, float(dcf_defaults["term_growth_pct"]), 0.25, key="dcf_term") / 100.0 + with col3: + base_growth = st.slider("Projected FCF Growth (Stage 1, Y1\u20135) %", -10.0, 30.0, float(dcf_defaults["fcf_growth_pct"]), 0.5, key="dcf_fcf_growth") / 100.0 + bull_growth = base_growth + 0.02 + bear_growth = base_growth - 0.02 + with st.expander("Reference: Analyst & Macro Assumptions", expanded=False): + left_col, right_col = st.columns(2) + with left_col: + st.markdown("**Analyst consensus (yfinance)**") + analyst = get_analyst_consensus(quant_ticker_t2) if quant_ticker_t2 else {} + _tmp = analyst.get('targetMeanPrice') + st.markdown(f"- **Target mean price:** ${_tmp:,.2f}" if _tmp else "- **Target mean price:** N/A") + st.markdown(f"- **Recommendation:** {analyst.get('recommendationKey', 'N/A')}") + st.markdown(f"- **Revenue growth est.:** {analyst.get('revenueGrowth', 'N/A')}") + st.markdown(f"- **Earnings growth est.:** {analyst.get('earningsGrowth', 'N/A')}") + with right_col: + st.markdown("**Aswath Damodaran \u2014 macro baseline**") + sector_name = get_sector_industry(ticker).get("sector", "N/A") if ticker else "N/A" + damodaran_wacc = _damodaran_wacc_for_sector(sector_name) if ticker else 8.0 + st.markdown(f"- **Sector WACC (ref.):** {damodaran_wacc:.1f}% (closest: {sector_name})") + st.markdown(f"- **US equity risk premium (ERP):** {DAMODARAN_ERP_PCT}%") + st.markdown(f"- **10Y risk-free rate:** {DAMODARAN_RF_PCT}%") + st.markdown("[Data & methodology (Damodaran)](https://pages.stern.nyu.edu/~adamodar/New_Home_Page/datafile/wacc.htm) so users can verify.") + res_base = excel_style_dcf(fcf, wacc, term_growth, base_growth, total_debt, cash, shares) + res_bull = excel_style_dcf(fcf, wacc, term_growth, bull_growth, total_debt, cash, shares) + res_bear = excel_style_dcf(fcf, wacc, term_growth, bear_growth, total_debt, cash, shares) + price_base = res_base.get("value_per_share") or 0.0 + price_bull = res_bull.get("value_per_share") or 0.0 + price_bear = res_bear.get("value_per_share") or 0.0 + current_price = None + if quant_ticker_t2 and yf: + try: + info = yf.Ticker(quant_ticker_t2.upper()).info or {} + current_price = info.get("currentPrice") or info.get("regularMarketPrice") or info.get("previousClose") + except Exception: + pass + st.markdown("**Intrinsic value vs current price**") + if current_price is not None and current_price > 0: + st.metric("Current price", f"${current_price:.2f}", None) + st.metric("Base case intrinsic value per share", f"${price_base:.2f}" if price_base else "N/A", f"vs current: {(price_base - current_price):.2f}" if (current_price and price_base) else None) + c1, c2, c3 = st.columns(3) + c1.metric("Bull (+2% FCF growth)", f"${price_bull:.2f}" if price_bull else "N/A", f"vs Base: +{(price_bull - price_base):.2f}" if (price_bull and price_base) else None) + c2.metric("Base", f"${price_base:.2f}" if price_base else "N/A", "\u2014") + c3.metric("Bear (\u22122% FCF growth)", f"${price_bear:.2f}" if price_bear else "N/A", f"vs Base: {(price_bear - price_base):.2f}" if (price_bear and price_base) else None) + df_dcf = pd.DataFrame({ + "Scenario": ["Bull", "Base", "Bear"], + "FCF Growth %": [f"{bull_growth*100:.1f}", f"{base_growth*100:.1f}", f"{bear_growth*100:.1f}"], + "Intrinsic Value ($)": [round(price_bull, 2) if price_bull else "N/A", round(price_base, 2) if price_base else "N/A", round(price_bear, 2) if price_bear else "N/A"], + }) + st.dataframe(df_dcf, use_container_width=True, hide_index=True) + + # --- Probability-Weighted Expected Return --- + st.markdown("**Probability-Weighted Expected Return**") + bull_prob, base_prob, bear_prob = 0.25, 0.55, 0.20 + pw_value = bull_prob * price_bull + base_prob * price_base + bear_prob * price_bear + if current_price and current_price > 0 and pw_value > 0: + pw_return = (pw_value - current_price) / current_price * 100 + st.markdown(f""" +
+
+
+
PROBABILITY-WEIGHTED VALUE
+
${pw_value:,.2f}
+
+
+
EXPECTED RETURN
+
{pw_return:+.1f}%
+
+
+ Bull {bull_prob*100:.0f}% x ${price_bull:,.0f} + Base {base_prob*100:.0f}% x ${price_base:,.0f} + Bear {bear_prob*100:.0f}% x ${price_bear:,.0f} +
+
+
+ """, unsafe_allow_html=True) + + # --- Analyst Consensus --- + st.markdown("---") + st.markdown("#### Analyst Consensus") + _render_analyst_consensus(quant_ticker_t2) + + # --- FCFF / FCFE Analysis --- + st.markdown("---") + st.markdown("#### FCFF / FCFE Analysis") + fcff_data = get_fcff_fcfe_valuation(quant_ticker_t2) if quant_ticker_t2 else {} + if fcff_data: + c1_ff, c2_ff, c3_ff, c4_ff = st.columns(4) + with c1_ff: + fcff_val = fcff_data.get("fcff") + st.metric("FCFF", f"${fcff_val/1e9:.2f}B" if fcff_val and abs(fcff_val) >= 1e9 else (f"${fcff_val/1e6:.0f}M" if fcff_val else "N/A")) + with c2_ff: + fcfe_val = fcff_data.get("fcfe") + st.metric("FCFE", f"${fcfe_val/1e9:.2f}B" if fcfe_val and abs(fcfe_val) >= 1e9 else (f"${fcfe_val/1e6:.0f}M" if fcfe_val else "N/A")) + with c3_ff: + _da_v = fcff_data.get("depreciation") + st.metric("D&A", f"${_da_v/1e9:.2f}B" if _da_v and abs(_da_v) >= 1e9 else (f"${_da_v/1e6:.0f}M" if _da_v else "N/A")) + with c4_ff: + _cx_v = fcff_data.get("capex") + st.metric("CapEx", f"${_cx_v/1e9:.2f}B" if _cx_v and abs(_cx_v) >= 1e9 else (f"${_cx_v/1e6:.0f}M" if _cx_v else "N/A")) + + with st.expander("FCFF/FCFE Bridge Detail", expanded=False): + def _fmt_b(v): + if v is None: return "N/A" + return f"${v/1e9:.2f}B" if abs(v) >= 1e9 else f"${v/1e6:.0f}M" + _ebit = fcff_data.get('ebit') or 0 + _tr_pct = fcff_data.get('tax_rate') or 21 + _da = fcff_data.get('depreciation') or 0 + _cx = fcff_data.get('capex') or 0 + bridge_data = { + "Component": ["EBIT", "x (1 - Tax Rate)", "= NOPAT", "+ D&A", "- CapEx", "= FCFF", "", "Net Income", "+ D&A", "- CapEx", "= FCFE"], + "Value": [ + _fmt_b(fcff_data.get('ebit')), + f"{_tr_pct:.1f}%", + _fmt_b(_ebit * (1 - _tr_pct/100)) if fcff_data.get('ebit') else "N/A", + _fmt_b(fcff_data.get('depreciation')), + _fmt_b(fcff_data.get('capex')), + _fmt_b(fcff_data.get('fcff')), + "---", + _fmt_b(fcff_data.get('net_income')), + _fmt_b(fcff_data.get('depreciation')), + _fmt_b(fcff_data.get('capex')), + _fmt_b(fcff_data.get('fcfe')), + ] + } + st.dataframe(pd.DataFrame(bridge_data), use_container_width=True, hide_index=True) + else: + st.caption("FCFF/FCFE data not available for this ticker.") + + # --- DCF Sensitivity Analysis --- + st.markdown("---") + st.markdown("#### DCF Sensitivity Analysis") + st.caption("Intrinsic value per share across WACC and Terminal Growth Rate assumptions") + if fcf and fcf > 0 and shares and shares > 0: + sens_df = _render_sensitivity_table(fcf, total_debt, cash, shares) + st.dataframe(sens_df, use_container_width=True) + else: + st.caption("Sensitivity table requires positive FCF data.") diff --git a/views/tab3_comps.py b/views/tab3_comps.py new file mode 100644 index 0000000..ab70d99 --- /dev/null +++ b/views/tab3_comps.py @@ -0,0 +1,82 @@ +import pandas as pd +import streamlit as st +from config.constants import SECTORS +from data.ratios import get_comps_data +from ai.gemini_insights import get_industry_outlook + + +def render_tab3(ticker): + st.subheader("Top-Down Sector Analysis") + st.markdown("Select an **industry** to load peer multiples (Forward P/E, EV/EBITDA, P/B). Green = lowest (undervalued), Red = highest. Optionally generate an **AI Industry Outlook**.") + sector_options = list(SECTORS.keys()) + selected_industry = st.selectbox("Select industry", sector_options, key="sector_select") + tickers_list = list(SECTORS.get(selected_industry, [])) + if not tickers_list: + st.warning("No tickers defined for this industry.") + else: + with st.spinner("Fetching market data..."): + df_comps = get_comps_data(tuple(tickers_list)) + if df_comps.empty: + st.warning("Could not fetch comps from yfinance. One or more tickers may have failed; try again later.") + else: + df_display = df_comps.copy() + for col in ["Forward P/E", "EV/EBITDA", "P/B"]: + if col not in df_display.columns: + continue + df_display[col] = df_display[col].apply( + lambda x: "N/A" if (x is None or (isinstance(x, float) and pd.isna(x))) else x + ) + try: + styled = df_comps.style + for col in ["Forward P/E", "EV/EBITDA", "P/B"]: + if col not in df_comps.columns: + continue + s = pd.to_numeric(df_comps[col], errors="coerce") + valid = s.dropna() + if len(valid) < 2: + continue + lo, hi = valid.min(), valid.max() + if lo == hi: + continue + def color_fn(v, lo_val=lo, hi_val=hi): + if pd.isna(v): + return "" + try: + x = float(v) + except (TypeError, ValueError): + return "" + if x <= lo_val: + return "background-color: rgba(0, 200, 83, 0.35); color: #0d5c2e" + if x >= hi_val: + return "background-color: rgba(255, 82, 82, 0.35); color: #b71c1c" + return "" + styled = styled.map(color_fn, subset=[col]) + styled = styled.format(subset=["Forward P/E", "EV/EBITDA", "P/B"], formatter=lambda x: "N/A" if (pd.isna(x) or x is None) else f"{x:.2f}") + st.dataframe(styled, use_container_width=True, hide_index=True) + except Exception: + st.dataframe(df_display, use_container_width=True, hide_index=True) + st.caption("Lowest multiple in each column = green (relatively undervalued); highest = red.") + + st.markdown("---") + st.markdown("#### AI Industry Outlook") + if st.button("Generate Industry Outlook", key="industry_outlook_btn"): + if not tickers_list: + st.error("Select an industry above first.") + elif not st.session_state.get("google_api_key"): + st.error("Enter your Google API Key in the sidebar.") + else: + try: + with st.spinner("Generating industry outlook with Gemini..."): + report = get_industry_outlook( + st.session_state["google_api_key"], + selected_industry, + tickers_list, + ) + st.success("Done.") + st.markdown(report) + except RuntimeError as e: + st.error(str(e)) + except Exception as e: + st.error("Failed to generate outlook. See details below.") + with st.expander("Error details"): + st.code(repr(e), language="text") diff --git a/views/tab4_news.py b/views/tab4_news.py new file mode 100644 index 0000000..e838a75 --- /dev/null +++ b/views/tab4_news.py @@ -0,0 +1,27 @@ +import streamlit as st +from config.constants import COMPANY_TICKER_MAP +from data.market import _fetch_news_rss + + +def render_tab4(ticker): + st.subheader("News Feed") + st.caption(f"Latest news for **{ticker}**") + try: + import feedparser + news_items = _fetch_news_rss(ticker, COMPANY_TICKER_MAP.get(ticker, "")) + if news_items: + for item in news_items: + st.markdown(f""" +
+ + {item['title']} + +
+ {item['source']} \u00b7 {item['published'][:25] if item['published'] else ''} +
+
+ """, unsafe_allow_html=True) + else: + st.info("No news found. Try a different ticker.") + except ImportError: + st.warning("Install `feedparser` to enable news feed: `pip install feedparser`") diff --git a/views/tab5_markets.py b/views/tab5_markets.py new file mode 100644 index 0000000..1399bb7 --- /dev/null +++ b/views/tab5_markets.py @@ -0,0 +1,58 @@ +import streamlit as st +try: + import yfinance as yf +except ImportError: + yf = None + + +def render_tab5(): + st.subheader("Markets & Foreign Exchange") + # FX Rates + st.markdown("#### \U0001f4b1 FX Rates") + fx_pairs = {"USD/KRW": "USDKRW=X", "GBP/USD": "GBPUSD=X", "EUR/USD": "EURUSD=X", "USD/JPY": "USDJPY=X"} + fx_cols = st.columns(len(fx_pairs)) + for i, (label, sym) in enumerate(fx_pairs.items()): + with fx_cols[i]: + try: + t = yf.Ticker(sym) + info = t.info or {} + price = info.get("regularMarketPrice") or info.get("previousClose") or 0 + prev = info.get("regularMarketPreviousClose") or price + chg = ((price - prev) / prev * 100) if prev else 0 + color = "#34D399" if chg >= 0 else "#F87171" + st.markdown(f""" +
+
{label}
+
{price:,.2f}
+
{chg:+.2f}%
+
+ """, unsafe_allow_html=True) + except Exception: + st.markdown(f"
{label}
N/A
", unsafe_allow_html=True) + + # Market Sector Heatmap + st.markdown("---") + st.markdown("#### \U0001f5fa\ufe0f Sector Performance") + sector_tickers = {"Technology": "XLK", "Healthcare": "XLV", "Financials": "XLF", "Energy": "XLE", "Consumer": "XLY", "Industrial": "XLI", "Utilities": "XLU", "Materials": "XLB", "Real Estate": "XLRE", "Communication": "XLC"} + sector_data = [] + for name, sym in sector_tickers.items(): + try: + t = yf.Ticker(sym) + info = t.info or {} + price = info.get("regularMarketPrice") or 0 + prev = info.get("regularMarketPreviousClose") or price + chg = ((price - prev) / prev * 100) if prev else 0 + sector_data.append({"sector": name, "change": chg}) + except Exception: + sector_data.append({"sector": name, "change": 0}) + # Render as colored grid + heatmap_cols = st.columns(5) + for i, s in enumerate(sector_data): + with heatmap_cols[i % 5]: + bg = f"rgba(52, 211, 153, {min(abs(s['change'])/3, 0.6)})" if s['change'] >= 0 else f"rgba(248, 113, 113, {min(abs(s['change'])/3, 0.6)})" + st.markdown(f""" +
+
{s['sector']}
+
{s['change']:+.2f}%
+
+ """, unsafe_allow_html=True) diff --git a/views/tab6_crypto.py b/views/tab6_crypto.py new file mode 100644 index 0000000..a9216e9 --- /dev/null +++ b/views/tab6_crypto.py @@ -0,0 +1,43 @@ +import pandas as pd +import streamlit as st +try: + import yfinance as yf +except ImportError: + yf = None + + +def render_tab6(): + st.subheader("Cryptocurrency Prices") + crypto_list = [ + ("Bitcoin", "BTC-USD"), ("Ethereum", "ETH-USD"), ("BNB", "BNB-USD"), + ("Solana", "SOL-USD"), ("XRP", "XRP-USD"), ("Cardano", "ADA-USD"), + ("Avalanche", "AVAX-USD"), ("Dogecoin", "DOGE-USD"), ("Polkadot", "DOT-USD"), + ("Chainlink", "LINK-USD"), ("Polygon", "MATIC-USD"), ("Litecoin", "LTC-USD"), + ] + crypto_rows = [] + for name, sym in crypto_list: + try: + t = yf.Ticker(sym) + info = t.info or {} + price = info.get("regularMarketPrice") or info.get("previousClose") or 0 + prev = info.get("regularMarketPreviousClose") or price + chg = ((price - prev) / prev * 100) if prev else 0 + mcap = info.get("marketCap") or 0 + crypto_rows.append({ + "Coin": name, + "Symbol": sym.replace("-USD", ""), + "Price (USD)": f"${price:,.2f}", + "24h Change": f"{chg:+.2f}%", + "Market Cap": f"${mcap/1e9:.1f}B" if mcap >= 1e9 else (f"${mcap/1e6:.0f}M" if mcap else "N/A"), + "_change": chg, + }) + except Exception: + crypto_rows.append({"Coin": name, "Symbol": sym.replace("-USD", ""), "Price (USD)": "N/A", "24h Change": "N/A", "Market Cap": "N/A", "_change": 0}) + if crypto_rows: + df_crypto = pd.DataFrame(crypto_rows) + def style_crypto(row): + chg = row.get("_change", 0) + color = "#34D399" if chg >= 0 else "#F87171" + return [f"color: {color}" if col == "24h Change" else "" for col in row.index] + display_df = df_crypto.drop(columns=["_change"]) + st.dataframe(display_df.style.apply(style_crypto, axis=1), use_container_width=True, hide_index=True) diff --git a/views/tab7_technical.py b/views/tab7_technical.py new file mode 100644 index 0000000..3c233e7 --- /dev/null +++ b/views/tab7_technical.py @@ -0,0 +1,77 @@ +import pandas as pd +import streamlit as st +from config.constants import MARKET_OPTIONS +from utils.ticker import get_global_ticker +from data.market import get_technical_indicators, get_risk_analysis + + +def render_tab7(ticker): + st.subheader("Technical Setup & Risk Analysis") + market_t7 = st.session_state.get("market") or MARKET_OPTIONS[0] + quant_ticker_t7 = get_global_ticker(ticker, market_t7) if ticker else "" + + st.markdown("#### Technical Indicators") + tech = get_technical_indicators(quant_ticker_t7) if quant_ticker_t7 else {} + if tech.get("current_price"): + tc1, tc2, tc3, tc4 = st.columns(4) + price_t7 = tech["current_price"] + with tc1: + rsi = tech.get("rsi_14") + rsi_color = "#F87171" if rsi and rsi > 70 else ("#34D399" if rsi and rsi < 30 else "#FBBF24") + rsi_label = "Overbought" if rsi and rsi > 70 else ("Oversold" if rsi and rsi < 30 else "Neutral") + st.markdown(f'
RSI (14)
{rsi if rsi else "N/A"}
{rsi_label}
', unsafe_allow_html=True) + with tc2: + sma50 = tech.get("sma_50") + above_50 = price_t7 > sma50 if sma50 else None + st.markdown(f'
SMA (50)
{"${:,.2f}".format(sma50) if sma50 else "N/A"}
{"Above" if above_50 else "Below"} SMA50
', unsafe_allow_html=True) + with tc3: + sma200 = tech.get("sma_200") + above_200 = price_t7 > sma200 if sma200 else None + st.markdown(f'
SMA (200)
{"${:,.2f}".format(sma200) if sma200 else "N/A"}
{"Above" if above_200 else "Below"} SMA200
', unsafe_allow_html=True) + with tc4: + h52 = tech.get("52w_high", 0) + l52 = tech.get("52w_low", 0) + st.markdown(f'
52W Range
${l52:,.2f} \u2014 ${h52:,.2f}
Current: ${price_t7:,.2f}
', unsafe_allow_html=True) + + st.markdown("---") + sr1, sr2 = st.columns(2) + with sr1: + st.markdown(f'
Support (20D Low)
${tech.get("support", 0):,.2f}
', unsafe_allow_html=True) + with sr2: + st.markdown(f'
Resistance (20D High)
${tech.get("resistance", 0):,.2f}
', unsafe_allow_html=True) + + sma50_v = tech.get("sma_50") + sma200_v = tech.get("sma_200") + if sma50_v and sma200_v: + if sma50_v > sma200_v: + st.markdown('
Golden Cross: SMA50 > SMA200 \u2014 Bullish Signal
', unsafe_allow_html=True) + else: + st.markdown('
Death Cross: SMA50 < SMA200 \u2014 Bearish Signal
', unsafe_allow_html=True) + else: + st.caption("Technical data not available. Enter a valid ticker.") + + st.markdown("---") + st.markdown("#### Risk Analysis Matrix") + risks = get_risk_analysis(quant_ticker_t7) if quant_ticker_t7 else [] + if risks: + risk_rows = [] + for r in risks: + risk_rows.append({"Risk Factor": r["risk"], "Severity": r["severity"], "Est. EPS Impact": r["eps_impact"], "Description": r["description"]}) + df_risks = pd.DataFrame(risk_rows) + def style_severity(val): + if val == "High": + return "background-color: rgba(248,113,113,0.2); color: #F87171; font-weight: 700" + elif val == "Medium": + return "background-color: rgba(251,191,36,0.2); color: #FBBF24; font-weight: 700" + return "background-color: rgba(52,211,153,0.2); color: #34D399; font-weight: 700" + styled_risks = df_risks.style.map(style_severity, subset=["Severity"]) + st.dataframe(styled_risks, use_container_width=True, hide_index=True) + total_impact = sum(float(r["eps_impact"].replace("$", "").replace("-", "")) for r in risks) + st.markdown(f""" +
+ Cumulative Worst-Case EPS Impact: + -${total_impact:.2f} +
+ """, unsafe_allow_html=True) + else: + st.caption("Risk analysis requires a valid ticker with financial data.")