commit 69a6a255f780fb04055bfa9c8a2f50de9b76204b Author: Seonpil Date: Thu Feb 12 14:48:19 2026 +0000 Initial commit: 10-K Financial Summarizer with Gemini AI Co-authored-by: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5685198 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Copy this file to .env and fill in your keys. +# Get Google API Key: https://aistudio.google.com/apikey + +GOOGLE_API_KEY=your_google_api_key_here +SEC_EDGAR_EMAIL=your_email@example.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e223540 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +venv/ +.env +__pycache__/ +.DS_Store + +# Streamlit +.streamlit/ + +# Python +*.py[cod] +*.egg-info/ +.eggs/ +dist/ +build/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo diff --git a/README.md b/README.md new file mode 100644 index 0000000..47619de --- /dev/null +++ b/README.md @@ -0,0 +1,145 @@ +# AI 기반 10-K 재무 분석기 + +사용자가 종목 티커(예: AAPL)를 입력하면 SEC EDGAR에서 최신 10-K를 가져와, Item 7(MD&A)와 Item 8(Financial Statements)을 바탕으로 CFA 관점의 3문장 요약과 핵심 재무 지표(Revenue, Net Income, Operating Cash Flow)를 보여주는 웹 앱입니다. + +## 기술 스택 + +- **UI**: Streamlit +- **데이터 수집**: sec-edgar-downloader (SEC EDGAR) +- **AI 분석**: Google Gemini 1.5 Pro (google-generativeai) + +## 필요한 것 + +- Python 3.9 이상 +- [Google API Key (Gemini)](https://aistudio.google.com/apikey) +- SEC EDGAR 접속 시 사용할 이메일 주소 (실명/실제 메일 권장) +- (선택) `.env` 파일: `GOOGLE_API_KEY`, `SEC_EDGAR_EMAIL` 설정 시 사이드바에 자동 반영 + +--- + +## 실행 방법 (단계별) + +**한 줄 요약:** 터미널에서 프로젝트 폴더로 간 뒤, 가상환경 켜고 `pip install -r requirements.txt` 한 다음 `streamlit run app.py` 입력하면 됩니다. + +### 1단계: 터미널 열기 + +- **Mac**: `Spotlight(Cmd+Space)` → "터미널" 입력 후 실행 +- **Windows**: `Win + R` → `cmd` 입력 후 실행 + +### 2단계: 프로젝트 폴더로 이동 + +```bash +cd "/Users/seonpil/Documents/FQDC Project" +``` + +(다른 위치에 프로젝트를 둔 경우 해당 폴더 경로로 바꿔 주세요.) + +### 3단계: 가상환경 만들기 (권장) + +한 번만 하면 됩니다. + +```bash +python3 -m venv venv +``` + +### 4단계: 가상환경 켜기 + +**Mac / Linux:** + +```bash +source venv/bin/activate +``` + +**Windows (명령 프롬프트):** + +```bash +venv\Scripts\activate.bat +``` + +**Windows (PowerShell):** + +```bash +venv\Scripts\Activate.ps1 +``` + +프롬프트 앞에 `(venv)`가 보이면 성공입니다. + +### 5단계: 패키지 설치 + +```bash +pip install -r requirements.txt +``` + +**Gemini 전용으로 새로 설치하는 경우 (기존 앤스로픽 제거 후):** + +```bash +pip uninstall anthropic -y +pip install google-generativeai python-dotenv +# 또는 전체 재설치 +pip install -r requirements.txt +``` + +인터넷이 필요하며, 1~2분 정도 걸릴 수 있습니다. + +### 6단계: 앱 실행 + +```bash +streamlit run app.py +``` + +브라우저가 자동으로 열리며 `http://localhost:8501` 에서 앱이 실행됩니다. +자동으로 안 열리면 브라우저 주소창에 `http://localhost:8501` 을 입력하세요. + +### 7단계: 설정 및 분석 + +1. **왼쪽 사이드바**에서 + - **Anthropic API Key**: [Anthropic 콘솔](https://console.anthropic.com)에서 발급한 키 입력 + - **SEC EDGAR 이메일 주소**: 본인 이메일 입력 (SEC 정책 준수용) +2. 메인 화면에서 **종목 티커** 입력 (예: `AAPL`, `MSFT`) +3. **분석 실행** 버튼 클릭 +4. 10-K 다운로드 및 AI 분석이 끝나면, + - **CFA 관점 3문장 요약** (재무 건전성, 수익성, 리스크) + - **Revenue / Net Income / Operating Cash Flow 표** + 를 확인할 수 있습니다. + +--- + +## 종료 방법 + +터미널에서 `Ctrl + C` 를 누르면 앱이 종료됩니다. + +--- + +## 폴더 구조 + +``` +FQDC Project/ +├── app.py # Streamlit 앱 (메인, Gemini 1.5 Pro) +├── requirements.txt # 필요한 라이브러리 목록 +├── .env.example # API 키 예시 (복사 후 .env 로 저장해 사용) +└── README.md # 이 파일 +``` + +## 문제 해결 + +- **"10-K 파일을 찾을 수 없습니다"** + - 티커가 정확한지 확인 (예: AAPL, MSFT) + - 인터넷 연결 확인 + - SEC EDGAR 이메일을 입력했는지 확인 + +- **"Anthropic API Key를 입력해 주세요"** + - 사이드바에서 API Key를 입력했는지 확인 + +- **패키지 설치 오류** + - `pip install --upgrade pip` 후 다시 `pip install -r requirements.txt` + +- **한글 깨짐** + - 터미널/IDE 인코딩을 UTF-8로 설정해 보세요. + +--- + +## 라이선스 및 면책 + +이 프로젝트는 학습·포트폴리오 목적입니다. +SEC 데이터 사용 시 [SEC 정책](https://www.sec.gov/os/webmaster-faq#code-support)을, +AI 결과 활용 시 Anthropic 이용약관을 준수해 주세요. diff --git a/app.py b/app.py new file mode 100644 index 0000000..5f8f45a --- /dev/null +++ b/app.py @@ -0,0 +1,411 @@ +""" +10-K Financial Analyzer (Google Gemini 1.5 Flash) +- Download 10-K from SEC EDGAR and extract text +- Analysis using Item 7 (MD&A) and Item 8 (Financial Statements) via Gemini 1.5 Flash (generous free tier, large context) +- CFA-style summary, key metrics table, and CFA Investment Report section +- All content in British English. +""" + +import json +import os +import re +import tempfile +import time +from pathlib import Path +from typing import Optional + +import streamlit as st +import pandas as pd +from bs4 import BeautifulSoup + +# Load .env if python-dotenv is available +try: + from dotenv import load_dotenv + load_dotenv() +except ImportError: + pass + +def get_edgar_downloader(): + from sec_edgar_downloader import Downloader + return Downloader + + +def extract_text_from_html(html_path: Path) -> str: + """Extract plain text from an HTML file.""" + try: + with open(html_path, "r", encoding="utf-8", errors="replace") as f: + soup = BeautifulSoup(f.read(), "lxml") + except Exception: + with open(html_path, "r", encoding="latin-1", errors="replace") as f: + soup = BeautifulSoup(f.read(), "lxml") + for tag in soup(["script", "style"]): + tag.decompose() + return soup.get_text(separator="\n", strip=True) + + +def extract_text_from_file(file_path: Path) -> str: + """Extract text by file extension (HTML or TXT).""" + 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_item_section(text: str, item_num: int, title_keywords: list) -> str: + """Find Item N section (e.g. item_num=7 -> Item 7, item_num=8 -> Item 8).""" + 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+\s+", text[start + 50 :], re.IGNORECASE) + if next_item: + end = start + 50 + next_item.start() + else: + end = min(start + 150000, len(text)) # Cap section size to stay within token limits + return text[start:end].strip() + + +def find_downloaded_10k_path(download_root: Path, ticker: str) -> Optional[Path]: + """Return the path to the latest 10-K folder for the given ticker under download_root.""" + 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 get_main_10k_text(filing_dir: Path) -> str: + """Find the main document (HTML/TXT) in the 10-K folder and return its full text.""" + 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_path, main_text = max(all_text, key=lambda x: len(x[1])) + return main_text + + +# ---------- Gemini 1.5 Flash: stable, generous free tier, good for large 10-K text ---------- +GEMINI_MODEL = "gemini-2.0-flash" +# Wait 1 minute before retry when rate limited (free tier resets after a short period) +RATE_LIMIT_WAIT_SEC = 60 +DELAY_BETWEEN_CALLS_SEC = 8 + + +def get_gemini_model(api_key: str): + """Return configured Gemini Flash model (generous free tier for large documents).""" + 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, generation_config, max_retries: int = 3): + """Call model.generate_content with retry on 429 (wait then retry up to max_retries times).""" + last_err = None + for attempt in range(max_retries + 1): + try: + return model.generate_content(content, generation_config=generation_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 get_ai_summary_and_report( + api_key: str, + full_text: str, + item7_text: str, + item8_text: str, + ticker: str, +) -> tuple[str, str]: + """ + Use Gemini 1.5 Flash to produce: + 1) A detailed three-part summary (financial health, profitability, key risks). + 2) A CFA Investment Report-style section (Executive Summary, Investment Thesis, Risks, etc.). + Only Item 7 and Item 8 are sent; sections are trimmed to avoid token/rate limits. + """ + model = get_gemini_model(api_key) + + # Keep payload smaller to reduce token usage and avoid 429 rate limits + max_chars_per_section = 40000 + if len(item7_text) > max_chars_per_section: + item7_text = item7_text[:max_chars_per_section] + "\n\n[ ... section truncated ... ]" + if len(item8_text) > max_chars_per_section: + item8_text = item8_text[:max_chars_per_section] + "\n\n[ ... section truncated ... ]" + + user_prompt = f"""You are a CFA charterholder and senior equity analyst writing for an accounting and finance audience. Your analysis must be evidence-based, cite specific figures from the 10-K where relevant, and follow professional investment report standards. Use British English throughout (e.g. analyse, summarise, colour, favour, organisation). + +Analyse the following 10-K content for company ticker: {ticker}. + +Use the FULL text provided below (Item 7 MD&A and Item 8 Financial Statements) to produce a thorough, detailed analysis. Do not summarise superficially—reference specific numbers, trends, and risk disclosures. + +First, write a "DETAILED ANALYSIS" section with exactly three paragraphs (use subheadings): +1. **Financial Health**: Liquidity (current ratio, cash position, credit facilities), leverage (debt/equity, interest coverage), capital structure, and any covenant or refinancing risks. Cite figures from the statements. +2. **Profitability**: Revenue and earnings trends, margins (gross, operating, net), earnings quality (e.g. non-GAAP adjustments, one-time items), and sustainability of earnings. Use numbers from the 10-K. +3. **Key Risks**: Material risk factors from MD&A and notes (market, credit, operational, legal, ESG if material). Be specific; quote or paraphrase the filing. + +Then, write a "CFA INVESTMENT REPORT" section in the style of a formal sell-side or buy-side investment memo. Include these subsections with clear headings: +- **Executive Summary**: 2–3 sentences on the company's position and your high-level view. +- **Investment Thesis**: Why an investor might consider this company (strengths, catalysts). Be specific. +- **Valuation Considerations**: What to watch (multiples, growth, margins, capital allocation). No exact price target required. +- **Key Risks to the Thesis**: Main downside risks that could invalidate the thesis. +- **Conclusion**: One short paragraph with a balanced wrap-up (e.g. Hold/Overweight/Underweight context and what would change your view). + +Keep the entire response in British English. Use clear section headers (e.g. ## or **) and professional language.""" + + full_content = f"""--- Item 7. Management's Discussion and Analysis (full or extended excerpt) --- + +{item7_text} + +--- Item 8. Financial Statements and Notes (full or extended excerpt) --- + +{item8_text} + +--- + +{user_prompt}""" + + try: + response = _generate_with_retry( + model, + full_content, + {"temperature": 0.3, "max_output_tokens": 8192}, + ) + 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() + + # Split into "DETAILED ANALYSIS" and "CFA INVESTMENT REPORT" if the model used those headers + detailed = "" + report = "" + if "CFA INVESTMENT REPORT" in text.upper() or "CFA Investment Report" in text: + 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 "" + if not detailed: + detailed = text + else: + detailed = text + report = "(CFA Investment Report section not clearly separated; full analysis above.)" + + return detailed, report + + +def get_metrics_table_from_ai(api_key: str, item8_text: str, ticker: str) -> pd.DataFrame: + """Ask Gemini to extract Revenue, Net Income, Operating Cash Flow by year from Item 8 and return a table.""" + model = get_gemini_model(api_key) + max_item8_chars = 25000 + excerpt = item8_text[:max_item8_chars] if len(item8_text) > max_item8_chars else item8_text + + prompt = f"""You are a financial analyst. From the 10-K Item 8 excerpt below for company {ticker}, extract the following for the most recent 3–5 fiscal years (if available): +- Revenue (or Net sales) +- Net Income (or Net earnings attributable to common shareholders) +- Cash flows from operating activities (Operating Cash Flow) + +Reply with ONLY a single JSON object, no other text. Use fiscal years as keys (e.g. "2023", "2022", "2021"). +Format: +{{"Revenue": {{"2023": 123.45, "2022": 100.0}}, "Net Income": {{"2023": 20.0, "2022": 18.0}}, "Operating Cash Flow": {{"2023": 25.0, "2022": 22.0}}}} +Use numbers in millions (e.g. 394328 for $394,328 million). If a value is not found, use null. + +Item 8 excerpt: + +{excerpt}""" + + try: + response = _generate_with_retry( + model, + prompt, + {"temperature": 0.1, "max_output_tokens": 1024}, + ) + 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 pd.DataFrame() + + text = response.text.strip() + json_match = re.search(r"\{[\s\S]*\}", text) + if not json_match: + return pd.DataFrame() + try: + data = json.loads(json_match.group()) + return pd.DataFrame(data) + except Exception: + return pd.DataFrame() + + +def run_analysis(ticker: str, api_key: str, email: str, analysis_only: bool = False) -> tuple[str, str, str, pd.DataFrame]: + """Download 10-K, extract text, get Item 7/8, return detailed summary, CFA report, and optionally metrics table.""" + 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.") + + item7 = find_item_section(full_text, 7, ["Management's Discussion", "MD&A", "Analysis"]) + item8 = find_item_section(full_text, 8, ["Financial Statements", "Consolidated"]) + + # Use only extracted Item 7 / Item 8; fallback to trimmed full text if sections not found + if not item7: + item7 = full_text[:80000] + if not item8: + item8 = full_text[80000:160000] if len(full_text) > 80000 else full_text[:80000] + + detailed_summary, cfa_report = get_ai_summary_and_report(api_key, full_text, item7, item8, ticker) + if analysis_only: + df_metrics = pd.DataFrame() + else: + time.sleep(DELAY_BETWEEN_CALLS_SEC) + df_metrics = get_metrics_table_from_ai(api_key, item8, ticker) + + return detailed_summary, cfa_report, full_text, df_metrics + + +# ---------- Streamlit UI ---------- +st.set_page_config(page_title="10-K Financial Analyzer", layout="wide") +st.title("10-K Financial Analyzer") +st.caption("Download 10-K from SEC EDGAR; view detailed analysis and a CFA-style investment report. Powered by Google Gemini.") + +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 (Google AI Studio).", + ) + 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. Turn on if you often hit rate limits.", + ) + st.session_state["google_api_key"] = google_api_key + st.session_state["email"] = email + st.session_state["analysis_only"] = analysis_only + +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'.") + st.stop() + +if st.button("Run Analysis"): + 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. You may also set GOOGLE_API_KEY in a .env file.") + 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) + with st.spinner("Downloading 10-K and running Gemini analysis (if rate limited, waiting up to 60s before retry)..."): + try: + detailed_summary, cfa_report, full_text, df_metrics = run_analysis(ticker, api_key, email, analysis_only=analysis_only) + st.success("Analysis complete.") + + st.subheader("Detailed Analysis (Financial Health, Profitability, Key Risks)") + st.markdown(detailed_summary) + + st.subheader("CFA Investment Report") + st.markdown(cfa_report) + + st.subheader("Key Financial Metrics (Revenue, Net Income, Operating Cash Flow)") + if not df_metrics.empty: + st.dataframe(df_metrics, use_container_width=True) + elif analysis_only: + st.info("Metrics skipped (Analysis only mode). Turn off 'Analysis only' in Settings to fetch metrics.") + else: + st.info("No metrics extracted. Check the full Item 8 text.") + + 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 (1 API call). The limit is on Google's side — wait **2–5 minutes** without clicking, then press Run Analysis again.") + else: + st.info("Wait 2–5 minutes, then try again. Or enable 'Analysis only (1 API call)' in Settings to reduce usage.") + 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.") + if analysis_only: + st.warning("You already have **Analysis only** on. Google's free tier limit is reached — wait **2–5 minutes**, then press Run Analysis again (no need to change settings).") + else: + st.info("Wait 2–5 minutes, then retry. 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. Please try again later or check Google AI Studio for available models.") + 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.caption("Share this with support if the issue continues.") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..99425e5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +streamlit>=1.28.0 +sec-edgar-downloader>=5.0.0 +google-generativeai>=0.8.0 +beautifulsoup4>=4.12.0 +requests>=2.31.0 +pandas>=2.0.0 +lxml>=4.9.0 +python-dotenv>=1.0.0