mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-13 18:38:06 +00:00
docs: Update README — 3-tab system (10-K Insights, DCF, Comps) + Project Origin & Vision (English)
This commit is contained in:
@@ -1,22 +1,42 @@
|
||||
# 10-K Financial Analyzer
|
||||
# All-in-One Financial Analysis Dashboard
|
||||
|
||||
A web app that fetches the latest 10-K from SEC EDGAR for a given stock ticker and uses a **hybrid architecture**: **qualitative** analysis (Item 7 MD&A only) via **Google Gemini**, and **quantitative** metrics (Revenue, Net Income, Operating Cash Flow) from **yfinance**. CFA-style report and key financials in one place.
|
||||
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.
|
||||
|
||||
The app is organised into **three tabs:**
|
||||
|
||||
| Tab | Purpose |
|
||||
|-----|---------|
|
||||
| **1. 10-K Qualitative Insights** | SEC EDGAR 10-K → Item 1A (Risk Factors) + Item 7 (MD&A) → cleaned text → Gemini. Output: management’s tone (sentiment), key strategic shifts, and major hidden risks. |
|
||||
| **2. DCF Valuation** | yfinance for FCF, Debt, Cash, Shares. Sliders for WACC, terminal growth, FCF growth. **3-scenario model** (Bull / Base / Bear) with intrinsic value per share. No LLM. |
|
||||
| **3. Industry Comps** | Comma-separated competitor tickers → yfinance **Forward P/E**, **EV/EBITDA**, **P/B** → comparison table. |
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Hybrid processing (qualitative + quantitative):**
|
||||
- **Qualitative:** Only **Item 7 (MD&A)** is sent to Gemini for analysis of management’s strategy, market risks, and sentiment—no Item 8 (financial statements) to the AI, which cuts token use and avoids number hallucination.
|
||||
- **Quantitative:** Financial metrics (Revenue, Net Income, Operating Cash Flow) are fetched directly from **yfinance**—fast, accurate, and no extra API tokens.
|
||||
- **HTML cleansing:** Before sending Item 7 to the LLM, the app strips remaining HTML tags, collapses whitespace, and removes page numbers to compress tokens.
|
||||
- **Selective extraction:** The 10-K is parsed with regex; only content from Item 7 onward is used for AI; PART I and Items 1–6 are dropped.
|
||||
- **Smart chunking:** Long Item 7 text is trimmed to head + tail to stay within token limits.
|
||||
- **Two-step progress:** Step 1 (download + extract Item 7), Step 2 (Gemini analysis + yfinance metrics).
|
||||
- **Analysis only mode:** Optional hide for the metrics table (Gemini still runs once on Item 7).
|
||||
- **S&P 500 reference list:** Sample table of company names and tickers at the bottom for quick lookup.
|
||||
- **Tab 1 — 10-K Qualitative Insights:** Item 1A + Item 7 from SEC EDGAR; HTML cleaned; one Gemini call for tone, strategic shifts, and hidden risks.
|
||||
- **Tab 2 — DCF Valuation:** FCF, Debt, Cash, Shares from yfinance; WACC / terminal growth / FCF growth sliders; Bull/Base/Bear intrinsic value per share; no LLM.
|
||||
- **Tab 3 — Industry Comps:** Comma-separated tickers; Forward P/E, EV/EBITDA, P/B from yfinance; comparison table.
|
||||
- **Error handling:** Try/except for SEC EDGAR and yfinance; clear messages when data is missing or requests fail.
|
||||
- **UI:** Three-tab layout, sidebar for API key and SEC email, professional layout.
|
||||
|
||||
**Typical run time:** About **1–2 minutes** (one Gemini call; yfinance metrics are near-instant). If the API is rate-limited, the app waits 60 seconds and retries automatically.
|
||||
**Run time:** Tab 1 ≈ 1–2 min (one Gemini call); Tabs 2–3 use yfinance (seconds). Rate limit: 60s retry.
|
||||
|
||||
---
|
||||
|
||||
## Project Origin & Vision
|
||||
|
||||
### 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 Vision — Commercialization
|
||||
|
||||
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.
|
||||
|
||||
### Future Roadmap
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -61,15 +81,39 @@ For full technical notes and code references, see **[TECHNICAL_NOTES.md](./TECHN
|
||||
|
||||
## How to Run
|
||||
|
||||
**1. Go to the project folder**
|
||||
```bash
|
||||
cd "/Users/seonpil/Documents/FQDC Project"
|
||||
```
|
||||
|
||||
**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)`.
|
||||
|
||||
**3. Install dependencies** (only needed once, or when requirements change)
|
||||
```bash
|
||||
cd "/path/to/FQDC Project"
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**4. Start the app**
|
||||
```bash
|
||||
streamlit run app.py
|
||||
```
|
||||
|
||||
Open the sidebar to set **Google API Key** and **SEC EDGAR Email**, then enter a ticker (e.g. `AAPL`, `MSFT`) and click **Run Analysis**. Use **Analysis only (1 API call)** if you hit rate limits.
|
||||
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
|
||||
```
|
||||
|
||||
Open the sidebar to set **Google API Key** and **SEC EDGAR Email**, then use the three tabs (10-K Insights, DCF, Comps) as needed.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"""
|
||||
10-K Financial Analyzer (Google Gemini) — Hybrid Architecture
|
||||
- Download 10-K from SEC EDGAR; extract Item 7 (MD&A) only for AI.
|
||||
- Quantitative: financial metrics (Revenue, Net Income, Operating Cash Flow) from yfinance.
|
||||
- Qualitative: Item 7 only to Gemini for strategic direction, risks, and sentiment analysis.
|
||||
- HTML cleansing before sending text to LLM to minimise tokens.
|
||||
- All content in British English.
|
||||
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).
|
||||
- Tab 3: Industry Comps (yfinance multiples: Forward P/E, EV/EBITDA, P/B).
|
||||
- Cost-effective: Gemini only for text; all numbers from yfinance.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
@@ -25,6 +23,11 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import yfinance as yf
|
||||
except ImportError:
|
||||
yf = None
|
||||
|
||||
|
||||
def get_edgar_downloader():
|
||||
from sec_edgar_downloader import Downloader
|
||||
@@ -56,6 +59,11 @@ def extract_text_from_file(file_path: Path) -> str:
|
||||
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",
|
||||
@@ -64,7 +72,6 @@ ITEM7_PATTERNS = [
|
||||
ITEM8_PATTERNS = [
|
||||
r"Item\s+8\s*[.:]\s*Financial\s+Statements",
|
||||
r"ITEM\s+8\s*[.:]\s*Financial\s+Statements",
|
||||
r"Item\s+8\s*[.:]\s*[\w\s]+Consolidated\s+Financial",
|
||||
]
|
||||
|
||||
|
||||
@@ -77,13 +84,7 @@ def _find_section_start(text: str, patterns: list, item_num: int) -> int:
|
||||
return m.start() if m else -1
|
||||
|
||||
|
||||
def prefilter_after_item7(full_text: str) -> str:
|
||||
start = _find_section_start(full_text, ITEM7_PATTERNS, 7)
|
||||
return full_text[start:] if start >= 0 else full_text
|
||||
|
||||
|
||||
def find_item_section(text: str, item_num: int, title_keywords: list) -> str:
|
||||
patterns = ITEM7_PATTERNS if item_num == 7 else ITEM8_PATTERNS
|
||||
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(
|
||||
@@ -94,59 +95,45 @@ def find_item_section(text: str, item_num: int, title_keywords: list) -> str:
|
||||
if not match:
|
||||
return ""
|
||||
start = match.start()
|
||||
next_item = re.search(r"\n\s*Item\s+\d+\s+", text[start + 100 :], re.IGNORECASE)
|
||||
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 + 150000, len(text))
|
||||
end = min(start + max_chars, len(text))
|
||||
return text[start:end].strip()
|
||||
|
||||
|
||||
def smart_chunk(section: str, max_chars: int = 30000, head_ratio: float = 0.5) -> str:
|
||||
if len(section) <= max_chars:
|
||||
return section
|
||||
head_size = int(max_chars * head_ratio)
|
||||
tail_size = max_chars - head_size - 100
|
||||
return (
|
||||
section[:head_size]
|
||||
+ "\n\n[ ... middle omitted to stay within token limit ... ]\n\n"
|
||||
+ section[-tail_size:]
|
||||
)
|
||||
|
||||
|
||||
def clean_text_for_llm(text: str) -> str:
|
||||
"""
|
||||
Token-compression cleansing before sending to LLM: strip HTML remnants,
|
||||
collapse whitespace, remove page numbers and excessive special characters.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return ""
|
||||
# Remove any remaining HTML tags (safe on plain text)
|
||||
text = re.sub(r"<[^>]+>", " ", text)
|
||||
# Collapse multiple spaces to one
|
||||
text = re.sub(r"[ \t]+", " ", text)
|
||||
# Normalise line endings and collapse many blank lines to at most two newlines
|
||||
text = re.sub(r"\r\n?", "\n", text)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
lines = []
|
||||
for line in text.split("\n"):
|
||||
line = line.strip()
|
||||
# Drop lines that are only digits (page numbers) or only punctuation/dashes
|
||||
if not line:
|
||||
lines.append("")
|
||||
continue
|
||||
if re.fullmatch(r"\d+", line) or re.fullmatch(r"[\.\-\s\-]+", line):
|
||||
continue
|
||||
# Short boilerplate lines (e.g. "Page 1 of 2") — optional: drop very short lines that look like page refs
|
||||
if re.match(r"^(page\s+\d+|\d+)\s*$", line, re.IGNORECASE) and len(line) < 20:
|
||||
continue
|
||||
lines.append(line)
|
||||
# Rejoin and collapse again
|
||||
result = "\n".join(lines)
|
||||
result = re.sub(r"\n{3,}", "\n\n", result)
|
||||
return result.strip()
|
||||
|
||||
|
||||
def smart_chunk(section: str, max_chars: int = 20000, head_ratio: float = 0.5) -> str:
|
||||
if len(section) <= max_chars:
|
||||
return section
|
||||
head_size = int(max_chars * head_ratio)
|
||||
tail_size = max_chars - head_size - 100
|
||||
return section[:head_size] + "\n\n[ ... middle omitted ... ]\n\n" + 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):
|
||||
@@ -181,13 +168,41 @@ def get_main_10k_text(filing_dir: Path) -> str:
|
||||
continue
|
||||
if not all_text:
|
||||
return ""
|
||||
main_path, main_text = max(all_text, key=lambda x: len(x[1]))
|
||||
_, main_text = max(all_text, key=lambda x: len(x[1]))
|
||||
return main_text
|
||||
|
||||
|
||||
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)."""
|
||||
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}'. Check ticker and SEC EDGAR.")
|
||||
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
|
||||
)
|
||||
text_after_7 = full_text
|
||||
start7 = _find_section_start(full_text, ITEM7_PATTERNS, 7)
|
||||
if start7 >= 0:
|
||||
text_after_7 = full_text[start7:]
|
||||
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 = smart_chunk(text_after_7[:120000], max_chars=20000)
|
||||
return full_text, item1a, item7
|
||||
|
||||
|
||||
# ---------- Gemini (qualitative only) ----------
|
||||
GEMINI_MODEL = "gemini-2.0-flash"
|
||||
RATE_LIMIT_WAIT_SEC = 60
|
||||
DELAY_BETWEEN_CALLS_SEC = 8
|
||||
|
||||
|
||||
def get_gemini_model(api_key: str):
|
||||
@@ -198,19 +213,14 @@ def get_gemini_model(api_key: str):
|
||||
|
||||
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
|
||||
)
|
||||
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, generation_config, max_retries: int = 3):
|
||||
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=generation_config)
|
||||
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):
|
||||
@@ -220,256 +230,288 @@ def _generate_with_retry(model, content, generation_config, max_retries: int = 3
|
||||
raise last_err
|
||||
|
||||
|
||||
def get_metrics_from_yfinance(ticker: str) -> pd.DataFrame:
|
||||
"""
|
||||
Quantitative data: fetch Revenue, Net Income, Operating Cash Flow from yfinance
|
||||
(no LLM; fast and accurate). Returns a DataFrame suitable for Streamlit display.
|
||||
"""
|
||||
try:
|
||||
import yfinance as yf
|
||||
except ImportError:
|
||||
return pd.DataFrame()
|
||||
try:
|
||||
t = yf.Ticker(ticker.upper())
|
||||
financials = t.financials # annual income statement
|
||||
cashflow = t.cashflow # annual cash flow
|
||||
if financials is None or financials.empty:
|
||||
return pd.DataFrame()
|
||||
# Prefer common index names (yfinance varies by region)
|
||||
rev_row = None
|
||||
for name in ("Total Revenue", "Revenue", "Net Revenue", "Operating Revenue"):
|
||||
if name in financials.index:
|
||||
rev_row = financials.loc[name]
|
||||
break
|
||||
ni_row = None
|
||||
for name in ("Net Income", "Net Income Common Stockholders", "Net Income Including Noncontrolling Interests"):
|
||||
if name in financials.index:
|
||||
ni_row = financials.loc[name]
|
||||
break
|
||||
ocf_row = None
|
||||
if cashflow is not None and not cashflow.empty:
|
||||
for name in ("Operating Cash Flow", "Cash From Operating Activities", "Cash From Operations"):
|
||||
if name in cashflow.index:
|
||||
ocf_row = cashflow.loc[name]
|
||||
break
|
||||
# Align by date (columns are often datetime)
|
||||
dates = financials.columns.tolist()
|
||||
if not dates:
|
||||
return pd.DataFrame()
|
||||
# Sort descending (most recent first) and take up to 5 years
|
||||
dates = sorted(dates, reverse=True)[:5]
|
||||
cashflow_cols = list(cashflow.columns) if cashflow is not None and not cashflow.empty else []
|
||||
data = {}
|
||||
for d in dates:
|
||||
yr = d.year if hasattr(d, "year") else int(str(d)[:4])
|
||||
rev_val = (rev_row[d] / 1e6) if rev_row is not None and d in rev_row.index else None
|
||||
ni_val = (ni_row[d] / 1e6) if ni_row is not None and d in ni_row.index else None
|
||||
ocf_val = None
|
||||
if ocf_row is not None:
|
||||
if d in ocf_row.index:
|
||||
ocf_val = ocf_row[d] / 1e6
|
||||
else:
|
||||
for c in cashflow_cols:
|
||||
cy = c.year if hasattr(c, "year") else int(str(c)[:4])
|
||||
if cy == yr:
|
||||
ocf_val = ocf_row[c] / 1e6
|
||||
break
|
||||
data[yr] = {"Revenue": rev_val, "Net Income": ni_val, "Operating Cash Flow": ocf_val}
|
||||
df = pd.DataFrame(data).T
|
||||
df.index.name = "Fiscal Year"
|
||||
df = df.astype(float).round(2)
|
||||
return df
|
||||
except Exception:
|
||||
return pd.DataFrame()
|
||||
|
||||
|
||||
def get_ai_summary_and_report(api_key: str, item7_text: str, ticker: str) -> tuple[str, str]:
|
||||
"""
|
||||
Qualitative only: send Item 7 (MD&A) to Gemini. Focus on strategic direction,
|
||||
market risks, and sentiment—not on summarising financial statement numbers.
|
||||
"""
|
||||
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)
|
||||
item7_text = clean_text_for_llm(item7_text)
|
||||
item7_text = smart_chunk(item7_text, max_chars=20000)
|
||||
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 CFA charterholder and senior equity analyst. Use British English.
|
||||
user_prompt = f"""You are a senior equity analyst. Use British English.
|
||||
|
||||
The text below is Item 7 (Management's Discussion and Analysis) only from the 10-K for company ticker: {ticker}. Do NOT ask for financial statements or numbers—this is a qualitative analysis.
|
||||
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.
|
||||
|
||||
Your task:
|
||||
1. **Strategic direction**: How does management describe its strategy, priorities, and capital allocation? What are the main growth drivers or initiatives?
|
||||
2. **Market and business risks**: What material risks (competitive, regulatory, operational, macro) does management emphasise? Be specific and cite the wording where relevant.
|
||||
3. **Tone (Sentiment)**: Overall, is the tone of MD&A more positive, cautious, or negative? Highlight 2–3 phrases or themes that support your view.
|
||||
Provide a concise report with three sections:
|
||||
|
||||
Then write a "CFA INVESTMENT REPORT" section with:
|
||||
- **Executive Summary**: 2–3 sentences on the company's narrative and management's message.
|
||||
- **Investment Thesis**: Key strengths and catalysts from the discussion.
|
||||
- **Key Risks to the Thesis**: Main downside risks from the text.
|
||||
- **Conclusion**: Balanced wrap-up.
|
||||
1. **Management's Tone (Sentiment)**: Is the overall tone positive, cautious, or negative? Quote 1–2 short phrases that support your view.
|
||||
|
||||
Keep the entire response in British English. Use clear section headers. Do not invent figures—only refer to what is in the text."""
|
||||
2. **Key Strategic Shifts**: What strategic priorities or shifts does management emphasise (e.g. capital allocation, growth drivers, new segments)? Be specific.
|
||||
|
||||
full_content = f"""--- Item 7. Management's Discussion and Analysis (MD&A) ---\n\n{item7_text}\n\n---\n\n{user_prompt}"""
|
||||
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": 8192})
|
||||
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.", "No report generated."
|
||||
|
||||
text = response.text.strip()
|
||||
detailed, report = text, ""
|
||||
if "CFA INVESTMENT REPORT" in text.upper():
|
||||
parts = re.split(r"\n\s*(?:CFA INVESTMENT REPORT|CFA Investment Report)\s*\n", text, maxsplit=1, flags=re.IGNORECASE)
|
||||
detailed = (parts[0].replace("DETAILED ANALYSIS", "").strip() if parts else "").strip() or text
|
||||
report = parts[1].strip() if len(parts) > 1 else ""
|
||||
else:
|
||||
report = "(CFA Investment Report section not clearly separated; full analysis above.)"
|
||||
|
||||
return detailed, report
|
||||
return "No analysis generated."
|
||||
return response.text.strip()
|
||||
|
||||
|
||||
def download_and_extract_sections(ticker: str, email: str) -> tuple[str, str, str]:
|
||||
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 file. Check ticker '{ticker}' and SEC EDGAR response.")
|
||||
full_text = get_main_10k_text(filing_dir)
|
||||
if not full_text:
|
||||
raise ValueError("Could not extract text from the 10-K.")
|
||||
text_from_item7 = prefilter_after_item7(full_text)
|
||||
item7 = find_item_section(text_from_item7, 7, ["Management's Discussion", "MD&A", "Analysis"])
|
||||
item8 = find_item_section(text_from_item7, 8, ["Financial Statements", "Consolidated"])
|
||||
if not item7:
|
||||
item7 = smart_chunk(text_from_item7[:120000], max_chars=20000)
|
||||
if not item8:
|
||||
remainder = text_from_item7[100000:220000] if len(text_from_item7) > 100000 else text_from_item7
|
||||
item8 = smart_chunk(remainder, max_chars=20000)
|
||||
return full_text, item7, item8
|
||||
# ---------- yfinance: DCF inputs ----------
|
||||
@st.cache_data(ttl=300)
|
||||
def get_dcf_inputs(ticker: str) -> dict:
|
||||
"""Fetch FCF, Total Debt, Cash, Shares Outstanding for DCF. Returns dict or empty on failure."""
|
||||
if not yf:
|
||||
return {}
|
||||
try:
|
||||
t = yf.Ticker(ticker.upper())
|
||||
info = t.info
|
||||
cashflow = t.cashflow
|
||||
balance = t.balance_sheet
|
||||
if cashflow is None or cashflow.empty:
|
||||
return {}
|
||||
fcf_row = None
|
||||
for name in ("Free Cash Flow", "Cash From Operations"):
|
||||
if name in cashflow.index:
|
||||
fcf_row = cashflow.loc[name]
|
||||
break
|
||||
if fcf_row is None and len(cashflow.index) > 0:
|
||||
fcf_row = cashflow.iloc[0]
|
||||
latest_fcf = None
|
||||
if fcf_row is not None and len(fcf_row) > 0:
|
||||
try:
|
||||
latest_fcf = float(fcf_row.iloc[0])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if latest_fcf is not None and (latest_fcf != latest_fcf or latest_fcf <= 0):
|
||||
latest_fcf = None
|
||||
total_debt = info.get("Total Debt")
|
||||
cash = info.get("Cash And Cash Equivalents") or info.get("Cash")
|
||||
shares = info.get("Shares Outstanding") or info.get("Float Shares")
|
||||
if balance is not None and not balance.empty:
|
||||
if total_debt is None and "Total Debt" in balance.index:
|
||||
try:
|
||||
total_debt = float(balance.loc["Total Debt"].iloc[0])
|
||||
except (TypeError, ValueError, KeyError):
|
||||
pass
|
||||
if cash is None and "Cash And Cash Equivalents" in balance.index:
|
||||
try:
|
||||
cash = float(balance.loc["Cash And Cash Equivalents"].iloc[0])
|
||||
except (TypeError, ValueError, KeyError):
|
||||
pass
|
||||
return {
|
||||
"fcf": latest_fcf,
|
||||
"total_debt": total_debt if total_debt is not None else 0,
|
||||
"cash": cash if cash is not None else 0,
|
||||
"shares": shares if shares is not None and shares > 0 else None,
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def run_analysis(ticker: str, api_key: str, email: str, analysis_only: bool = False) -> tuple[str, str, str, pd.DataFrame]:
|
||||
full_text, item7, _ = download_and_extract_sections(ticker, email)
|
||||
detailed_summary, cfa_report = get_ai_summary_and_report(api_key, item7, ticker)
|
||||
if analysis_only:
|
||||
df_metrics = pd.DataFrame()
|
||||
else:
|
||||
df_metrics = get_metrics_from_yfinance(ticker)
|
||||
return detailed_summary, cfa_report, full_text, df_metrics
|
||||
def dcf_intrinsic_value(fcf: float, wacc: float, terminal_growth: float, revenue_growth: float, years: int = 10) -> float:
|
||||
"""DCF: project FCF with revenue_growth, terminal value with terminal_growth, discount at WACC. Returns enterprise value."""
|
||||
if fcf <= 0 or wacc <= terminal_growth:
|
||||
return 0.0
|
||||
pv = 0.0
|
||||
fcft = fcf
|
||||
for t in range(1, years + 1):
|
||||
pv += fcft / ((1 + wacc) ** t)
|
||||
fcft *= (1 + revenue_growth)
|
||||
terminal_fcf = fcft
|
||||
tv = terminal_fcf * (1 + terminal_growth) / (wacc - terminal_growth)
|
||||
pv += tv / ((1 + wacc) ** years)
|
||||
return pv
|
||||
|
||||
|
||||
# ---------- yfinance: Comps (multiples) ----------
|
||||
@st.cache_data(ttl=300)
|
||||
def get_comps_data(tickers: tuple) -> pd.DataFrame:
|
||||
"""Fetch Forward P/E, EV/EBITDA, P/B for each ticker. Returns styled DataFrame."""
|
||||
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
|
||||
forward_pe = info.get("Forward PE") or info.get("Trailing PE")
|
||||
pb = info.get("Price To Book")
|
||||
ev = info.get("Enterprise Value")
|
||||
ebitda = info.get("EBITDA")
|
||||
ev_ebitda = (ev / ebitda) if (ev is not None and ebitda is not None and ebitda != 0) else None
|
||||
rows.append({
|
||||
"Ticker": sym,
|
||||
"Forward P/E": round(forward_pe, 2) if forward_pe is not None else None,
|
||||
"EV/EBITDA": round(ev_ebitda, 2) if ev_ebitda is not None else None,
|
||||
"P/B": round(pb, 2) if 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)
|
||||
|
||||
|
||||
# ---------- Streamlit UI ----------
|
||||
st.set_page_config(page_title="10-K Financial Analyzer", layout="wide")
|
||||
st.title("10-K Financial Analyzer")
|
||||
st.caption("Hybrid: 10-K Item 7 (MD&A) → Gemini for sentiment & risks; financial metrics from yfinance. British English.")
|
||||
st.set_page_config(page_title="Financial Analysis Dashboard", layout="wide", initial_sidebar_state="expanded")
|
||||
|
||||
# Professional styling
|
||||
st.markdown("""
|
||||
<style>
|
||||
.stTabs [data-baseweb="tab-list"] { gap: 8px; }
|
||||
.stTabs [data-baseweb="tab"] { padding: 12px 24px; font-weight: 600; }
|
||||
div[data-testid="stMetricValue"] { font-size: 1.4rem; }
|
||||
.block-container { padding-top: 1.5rem; max-width: 1200px; }
|
||||
</style>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
st.title("All-in-One Financial Analysis Dashboard")
|
||||
st.caption("Hybrid: Gemini for qualitative (10-K MD&A & Risks); yfinance for quantitative (DCF, Comps). Cost-effective personal research.")
|
||||
|
||||
with st.sidebar:
|
||||
st.header("Settings")
|
||||
google_api_key = st.text_input("Google API Key (Gemini)", type="password", value=os.environ.get("GOOGLE_API_KEY", ""), help="Obtain from https://aistudio.google.com/apikey")
|
||||
email = st.text_input("SEC EDGAR Email Address", value=os.environ.get("SEC_EDGAR_EMAIL", ""), help="Required for SEC programmatic download policy compliance.")
|
||||
analysis_only = st.checkbox("Analysis only (1 API call)", value=False, help="Skip metrics table to use only 1 API call.")
|
||||
google_api_key = st.text_input(
|
||||
"Google API Key (Gemini)",
|
||||
type="password",
|
||||
value=os.environ.get("GOOGLE_API_KEY", ""),
|
||||
help="Required for Tab 1 (10-K insights).",
|
||||
)
|
||||
sec_email = st.text_input(
|
||||
"SEC EDGAR Email",
|
||||
value=os.environ.get("SEC_EDGAR_EMAIL", ""),
|
||||
help="Required for 10-K download.",
|
||||
)
|
||||
ticker = st.text_input("Primary Ticker", value="NVDA", max_chars=10).strip().upper()
|
||||
st.session_state["google_api_key"] = google_api_key
|
||||
st.session_state["email"] = email
|
||||
st.session_state["analysis_only"] = analysis_only
|
||||
st.session_state["sec_email"] = sec_email
|
||||
st.session_state["ticker"] = ticker
|
||||
|
||||
ticker = st.text_input("Stock Ticker (e.g. AAPL, MSFT)", value="AAPL", max_chars=10).strip().upper()
|
||||
if not ticker:
|
||||
st.info("Enter a ticker and click 'Run Analysis', or pick one from the S&P 500 list below.")
|
||||
ticker = st.session_state.get("ticker", "NVDA") or "NVDA"
|
||||
|
||||
SP500_SAMPLE = [
|
||||
("Apple Inc.", "AAPL"), ("Microsoft Corporation", "MSFT"), ("Amazon.com Inc.", "AMZN"),
|
||||
("NVIDIA Corporation", "NVDA"), ("Alphabet Inc. (Google)", "GOOGL"), ("Meta Platforms Inc. (Facebook)", "META"),
|
||||
("Berkshire Hathaway Inc.", "BRK.B"), ("Tesla Inc.", "TSLA"), ("JPMorgan Chase & Co.", "JPM"),
|
||||
("Visa Inc.", "V"), ("UnitedHealth Group Inc.", "UNH"), ("Procter & Gamble Co.", "PG"),
|
||||
("Exxon Mobil Corporation", "XOM"), ("Johnson & Johnson", "JNJ"), ("Mastercard Inc.", "MA"),
|
||||
("Chevron Corporation", "CVX"), ("Home Depot Inc.", "HD"), ("Merck & Co. Inc.", "MRK"),
|
||||
("AbbVie Inc.", "ABBV"), ("Costco Wholesale Corporation", "COST"), ("PepsiCo Inc.", "PEP"),
|
||||
("Coca-Cola Company", "KO"), ("Pfizer Inc.", "PFE"), ("Walmart Inc.", "WMT"), ("Netflix Inc.", "NFLX"),
|
||||
("Adobe Inc.", "ADBE"), ("Salesforce Inc.", "CRM"), ("Comcast Corporation", "CMCSA"), ("Cisco Systems Inc.", "CSCO"),
|
||||
("Oracle Corporation", "ORCL"), ("Intel Corporation", "INTC"), ("American Express Company", "AXP"),
|
||||
("Bank of America Corp.", "BAC"), ("Wells Fargo & Company", "WFC"), ("Verizon Communications Inc.", "VZ"),
|
||||
("AT&T Inc.", "T"), ("Disney (Walt Disney Co.)", "DIS"), ("Nike Inc.", "NKE"), ("McDonald's Corporation", "MCD"),
|
||||
("Starbucks Corporation", "SBUX"), ("Goldman Sachs Group Inc.", "GS"), ("Morgan Stanley", "MS"),
|
||||
]
|
||||
tab1, tab2, tab3 = st.tabs(["10-K & MD&A Insights", "3-Scenario DCF Valuation", "Industry Analysis & Comps"])
|
||||
|
||||
st.caption("Select a ticker above or choose from the list below.")
|
||||
# ----- Tab 1: 10-K & MD&A Insights -----
|
||||
with tab1:
|
||||
st.subheader("10-K & MD&A Insights (Qualitative)")
|
||||
st.markdown("Extract **Item 1A (Risk Factors)** and **Item 7 (MD&A)** from the latest 10-K. Gemini analyses: **Management's Tone**, **Strategic Shifts**, **Hidden Risks**.")
|
||||
if st.button("Run 10-K Analysis", key="run_10k"):
|
||||
if not ticker:
|
||||
st.error("Enter a ticker in the sidebar.")
|
||||
elif not st.session_state.get("google_api_key"):
|
||||
st.error("Enter your Google API Key in the sidebar.")
|
||||
elif not st.session_state.get("sec_email"):
|
||||
st.error("Enter your SEC EDGAR email in the sidebar.")
|
||||
else:
|
||||
try:
|
||||
with st.spinner("Downloading 10-K and extracting Item 1A & Item 7..."):
|
||||
full_text, item1a, item7 = download_and_extract_item7_and_1a(ticker, st.session_state["sec_email"])
|
||||
with st.spinner("Running Gemini analysis (tone, strategy, risks)..."):
|
||||
analysis = get_mda_insights(
|
||||
st.session_state["google_api_key"], item1a, item7, ticker
|
||||
)
|
||||
st.success("Analysis complete.")
|
||||
st.markdown(analysis)
|
||||
with st.expander("View raw excerpt (Item 1A + Item 7)"):
|
||||
excerpt = (item1a or "") + "\n\n---\n\n" + (item7 or "")
|
||||
st.text(excerpt[:12000] + ("..." if len(excerpt) > 12000 else ""))
|
||||
except FileNotFoundError as e:
|
||||
st.error(str(e))
|
||||
except ValueError as e:
|
||||
st.error(str(e))
|
||||
except RuntimeError as e:
|
||||
st.error(str(e))
|
||||
except Exception as e:
|
||||
st.error("An error occurred. See details below.")
|
||||
with st.expander("Error details"):
|
||||
st.code(repr(e), language="text")
|
||||
|
||||
if st.button("Run Analysis"):
|
||||
if not ticker:
|
||||
st.error("Please enter or select a stock ticker.")
|
||||
st.stop()
|
||||
api_key = st.session_state.get("google_api_key", "")
|
||||
email = st.session_state.get("email", "")
|
||||
if not api_key:
|
||||
st.error("Please enter your Google API Key (Gemini) in Settings.")
|
||||
st.stop()
|
||||
if not email:
|
||||
st.error("Please enter your SEC EDGAR email address in Settings.")
|
||||
st.stop()
|
||||
analysis_only = st.session_state.get("analysis_only", False)
|
||||
try:
|
||||
with st.spinner("Step 1/2: Downloading 10-K and extracting Item 7 (MD&A)..."):
|
||||
full_text, item7, _ = download_and_extract_sections(ticker, email)
|
||||
with st.spinner("Step 2/2: Running Gemini (qualitative analysis) and fetching financial metrics..."):
|
||||
detailed_summary, cfa_report = get_ai_summary_and_report(api_key, item7, ticker)
|
||||
if analysis_only:
|
||||
df_metrics = pd.DataFrame()
|
||||
# ----- Tab 2: 3-Scenario DCF -----
|
||||
with tab2:
|
||||
st.subheader("3-Scenario DCF Valuation (Quantitative)")
|
||||
st.markdown("Uses **yfinance** for FCF, Debt, Cash, Shares. No Gemini. Adjust assumptions with sliders.")
|
||||
dcf_inputs = get_dcf_inputs(ticker) if ticker else {}
|
||||
if not dcf_inputs:
|
||||
st.warning("Could not fetch DCF inputs from yfinance. Check ticker or try again.")
|
||||
else:
|
||||
fcf = dcf_inputs.get("fcf") or 0
|
||||
total_debt = dcf_inputs.get("total_debt") or 0
|
||||
cash = dcf_inputs.get("cash") or 0
|
||||
shares = dcf_inputs.get("shares")
|
||||
if fcf and fcf > 0 and shares and shares > 0:
|
||||
col1, col2, col3 = st.columns(3)
|
||||
with col1:
|
||||
wacc = st.slider("WACC (%)", 4.0, 20.0, 10.0, 0.5) / 100.0
|
||||
with col2:
|
||||
term_growth = st.slider("Terminal Growth Rate (%)", -2.0, 6.0, 2.0, 0.25) / 100.0
|
||||
with col3:
|
||||
base_growth = st.slider("Base Case FCF Growth (%)", -10.0, 30.0, 8.0, 0.5) / 100.0
|
||||
bull_growth = base_growth + 0.02
|
||||
bear_growth = base_growth - 0.02
|
||||
ev_base = dcf_intrinsic_value(fcf, wacc, term_growth, base_growth)
|
||||
ev_bull = dcf_intrinsic_value(fcf, wacc, term_growth, bull_growth)
|
||||
ev_bear = dcf_intrinsic_value(fcf, wacc, term_growth, bear_growth)
|
||||
equity_base = ev_base - total_debt + cash
|
||||
equity_bull = ev_bull - total_debt + cash
|
||||
equity_bear = ev_bear - total_debt + cash
|
||||
price_base = equity_base / shares if shares else 0
|
||||
price_bull = equity_bull / shares if shares else 0
|
||||
price_bear = equity_bear / shares if shares else 0
|
||||
st.markdown("#### Intrinsic Value per Share (3 Scenarios)")
|
||||
c1, c2, c3 = st.columns(3)
|
||||
c1.metric("Bull (+2% growth)", f"${price_bull:.2f}", "Base vs Bull")
|
||||
c2.metric("Base", f"${price_base:.2f}", "—")
|
||||
c3.metric("Bear (-2% growth)", f"${price_bear:.2f}", "Base vs Bear")
|
||||
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), round(price_base, 2), round(price_bear, 2)],
|
||||
})
|
||||
st.dataframe(df_dcf, use_container_width=True, hide_index=True)
|
||||
else:
|
||||
st.info("FCF or Shares Outstanding not available for this ticker. Try another.")
|
||||
|
||||
# ----- Tab 3: Industry Comps -----
|
||||
with tab3:
|
||||
st.subheader("Industry Analysis & Comps")
|
||||
st.markdown("Enter **comma-separated competitor tickers** (e.g. `AMD, INTC, QCOM`). Multiples from **yfinance**.")
|
||||
comp_tickers = st.text_input("Competitor tickers", value="AMD, INTC, QCOM", key="comps").strip()
|
||||
if st.button("Load Comps", key="load_comps"):
|
||||
tickers_list = [t.strip().upper() for t in comp_tickers.split(",") if t.strip()]
|
||||
if ticker and ticker not in tickers_list:
|
||||
tickers_list = [ticker] + tickers_list
|
||||
if not tickers_list:
|
||||
st.warning("Enter at least one ticker.")
|
||||
else:
|
||||
df_comps = get_comps_data(tuple(tickers_list))
|
||||
if df_comps.empty:
|
||||
st.warning("Could not fetch comps from yfinance.")
|
||||
else:
|
||||
df_metrics = get_metrics_from_yfinance(ticker)
|
||||
|
||||
st.success("Analysis complete.")
|
||||
st.subheader("Detailed Analysis (Strategy, Risks, Sentiment — from Item 7 MD&A)")
|
||||
st.markdown(detailed_summary)
|
||||
st.subheader("CFA Investment Report")
|
||||
st.markdown(cfa_report)
|
||||
st.subheader("Key Financial Metrics (Revenue, Net Income, Operating Cash Flow) — from yfinance")
|
||||
if not df_metrics.empty:
|
||||
st.dataframe(df_metrics, use_container_width=True)
|
||||
st.caption("Values in millions (USD). Source: yfinance.")
|
||||
elif analysis_only:
|
||||
st.info("Metrics skipped (Analysis only mode).")
|
||||
else:
|
||||
st.info("No metrics available for this ticker from yfinance.")
|
||||
with st.expander("View excerpt of extracted 10-K text"):
|
||||
st.text(full_text[:15000] + ("..." if len(full_text) > 15000 else ""))
|
||||
|
||||
except FileNotFoundError as e:
|
||||
st.error(str(e))
|
||||
except ValueError as e:
|
||||
st.error(str(e))
|
||||
except RuntimeError as e:
|
||||
st.error(str(e))
|
||||
if analysis_only:
|
||||
st.warning("You already have Analysis only on. Wait 2–5 minutes, then try again.")
|
||||
else:
|
||||
st.info("Wait 2–5 minutes, or enable Analysis only (1 API call) in Settings.")
|
||||
except Exception as e:
|
||||
err_msg = str(e).lower()
|
||||
if "429" in err_msg or ("resource" in err_msg and "exhausted" in err_msg):
|
||||
st.error("Rate limit exceeded. Please try again in a few minutes.")
|
||||
st.info("Wait 2–5 minutes, or enable **Analysis only (1 API call)** in the sidebar.")
|
||||
elif "404" in err_msg or "not found" in err_msg:
|
||||
st.error("The selected model is not available. Check Google AI Studio for available models.")
|
||||
elif "timeout" in err_msg or "retryerror" in err_msg or "600" in err_msg:
|
||||
st.error("Request timed out. The API took too long to respond.")
|
||||
st.info("Try again, or enable **Analysis only (1 API call)** to send less data.")
|
||||
else:
|
||||
st.error("An error occurred. Please try again later.")
|
||||
st.caption("If the problem persists, check your API key and internet connection.")
|
||||
with st.expander("Error details (for troubleshooting)"):
|
||||
st.code(repr(e), language="text")
|
||||
st.dataframe(df_comps, use_container_width=True, hide_index=True)
|
||||
|
||||
st.divider()
|
||||
st.subheader("S&P 500 companies (sample) — Company name & Ticker")
|
||||
st.caption("Type a ticker from the list into the box above.")
|
||||
df_sp = pd.DataFrame(SP500_SAMPLE, columns=["Company name", "Ticker"])
|
||||
with st.expander("Show list", expanded=True):
|
||||
with st.expander("S&P 500 sample — Company & Ticker"):
|
||||
SP500_SAMPLE = [
|
||||
("NVIDIA Corporation", "NVDA"), ("Apple Inc.", "AAPL"), ("Microsoft Corporation", "MSFT"),
|
||||
("Amazon.com Inc.", "AMZN"), ("Alphabet Inc. (Google)", "GOOGL"), ("Meta Platforms Inc.", "META"),
|
||||
("AMD", "AMD"), ("Intel Corporation", "INTC"), ("Qualcomm Inc.", "QCOM"),
|
||||
]
|
||||
df_sp = pd.DataFrame(SP500_SAMPLE, columns=["Company", "Ticker"])
|
||||
st.dataframe(df_sp, use_container_width=True, hide_index=True)
|
||||
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
# Push current (English) version to GitHub
|
||||
# Run from project root: bash push_to_github.sh
|
||||
|
||||
set -e
|
||||
cd "/Users/seonpil/Documents/FQDC Project"
|
||||
|
||||
echo "--- Git status ---"
|
||||
git status
|
||||
|
||||
echo ""
|
||||
echo "--- Add all changes ---"
|
||||
git add -A
|
||||
|
||||
echo ""
|
||||
echo "--- Commit (README: 3-tab architecture + Project Origin & Vision) ---"
|
||||
git commit -m "docs: Update README — 3-tab system (10-K Insights, DCF, Comps) + Project Origin & Vision (English)"
|
||||
|
||||
echo ""
|
||||
echo "--- Push to origin main ---"
|
||||
git push origin main
|
||||
|
||||
echo ""
|
||||
echo "Done. Check https://github.com/shawnkim1997/10-K-summariser-project"
|
||||
Reference in New Issue
Block a user