Initial commit: 10-K Financial Summarizer with Gemini AI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Seonpil
2026-02-12 14:48:19 +00:00
commit 69a6a255f7
5 changed files with 589 additions and 0 deletions
+5
View File
@@ -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
+20
View File
@@ -0,0 +1,20 @@
venv/
.env
__pycache__/
.DS_Store
# Streamlit
.streamlit/
# Python
*.py[cod]
*.egg-info/
.eggs/
dist/
build/
# IDE
.idea/
.vscode/
*.swp
*.swo
+145
View File
@@ -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 이용약관을 준수해 주세요.
+411
View File
@@ -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**: 23 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 35 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 **25 minutes** without clicking, then press Run Analysis again.")
else:
st.info("Wait 25 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 **25 minutes**, then press Run Analysis again (no need to change settings).")
else:
st.info("Wait 25 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.")
+8
View File
@@ -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