diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..60432b2 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,13 @@ +# .cursorrules +You are an expert Python/Streamlit Quant Developer. +Due to the large codebase, you MUST adhere to the following strict rules: + +1. ZERO UNAUTHORIZED DELETION: NEVER delete, comment out, or overwrite existing features, tables, charts, or logic UNLESS explicitly told to do so. +2. MODULARITY: Keep changes strictly scoped to the specific file or function requested. Do not touch other files. +3. PRESERVE LOGIC: Never remove existing defensive math logic (safe_div, try-except, YoY calculations). +4. VERIFY BEFORE CUT: If moving code to a different file, ensure the code is completely pasted into the new file BEFORE removing it from the original file. +5. FILE LENGTH MONITORING (AUTO-WARNING): +Before you propose or make any edits, you MUST check the total line count of the target file. +If the file exceeds 800 lines, you MUST start your chat response with this exact warning in bold: +"🚨 **[ARCHITECT WARNING] This file is getting too fat (>800 lines)! Consider splitting it into Services (logic) and Views (UI) to prevent context overflow.**" +You must still complete the user's requested edit, but always display this warning first so the user is aware of the technical debt. diff --git a/.gitignore b/.gitignore index 2dd5367..2286a70 100644 --- a/.gitignore +++ b/.gitignore @@ -20,5 +20,32 @@ build/ # IDE .idea/ .vscode/ +.cursor/ *.swp *.swo + +# Node / Next.js +node_modules/ +.next/ +.turbo/ + +# SQLite +*.db +*.db-shm +*.db-wal + +# Claude +.claude/ + +# Package locks (keep only package.json) +package-lock.json + +# SEC EDGAR cache +SEC-EDGAR-Filings/ + +# Backup directories +*-backup/ + +# Prompt files +CLAUDE_CODE_PROMPT.md +REFACTORING_PROMPT.md diff --git a/ATLAS_EVALUATION.md b/ATLAS_EVALUATION.md new file mode 100644 index 0000000..4b79266 --- /dev/null +++ b/ATLAS_EVALUATION.md @@ -0,0 +1,167 @@ +# ATLAS Terminal β€” μ’…ν•© ν”„λ‘œμ νŠΈ 평가 & λΉŒλ”© κ°€μ΄λ“œ + +--- + +## PART 1: ν˜„μž¬ ν”„λ‘œμ νŠΈ 평가 + +### 🟒 μž˜ν•œ 점 (Strengths) + +**1. ν•˜μ΄λΈŒλ¦¬λ“œ μ•„ν‚€ν…μ²˜ β€” 이건 μ§„μ§œ μ’‹λ‹€** +μ •μ„±(LLM)κ³Ό μ •λŸ‰(Pandas/yfinance)을 λΆ„λ¦¬ν•œ μ„€κ³„λŠ” λΉ„μš© νš¨μœ¨μ„±κ³Ό 정확도λ₯Ό λ™μ‹œμ— μž‘λŠ” ν”„λ‘œλ•μ…˜ 레벨 νŒλ‹¨μ΄λ‹€. Geminiλ₯Ό ν…μŠ€νŠΈ λΆ„μ„μ—λ§Œ μ“°κ³ , μˆ«μžλŠ” 무료 APIμ—μ„œ κ°€μ Έμ˜€λŠ” κ΅¬μ‘°λŠ” μ‹€μ œ FinTech μŠ€νƒ€νŠΈμ—…μ—μ„œλ„ μ±„νƒν•˜λŠ” νŒ¨ν„΄μ΄λ‹€. + +**2. 429 토큰 μ΅œμ ν™” β€” μ‹€μ „ 문제 ν•΄κ²°** +200νŽ˜μ΄μ§€ 10-Kλ₯Ό ν†΅μ§Έλ‘œ 보내지 μ•Šκ³ , Item 1A~9A만 μŠ¬λΌμ΄μ‹± β†’ regex νŒŒμ‹± β†’ smart_chunk둜 head+tail λ³΄μ‘΄ν•˜λŠ” μ „λž΅μ€ 토큰 80%+ μ ˆκ°μ„ λ‹¬μ„±ν–ˆλ‹€. 이건 λ©΄μ ‘μ—μ„œ κ°•λ ₯ν•œ μŠ€ν† λ¦¬κ°€ λœλ‹€. + +**3. 닀단계 폴백 μ‹œμŠ€ν…œ** +yahooquery β†’ yfinance(fast_info β†’ info β†’ balance_sheet) β†’ TTM/λΆ„κΈ° ν•©μ‚° β†’ μˆ˜λ™ μž…λ ₯ 순으둜 λ–¨μ–΄μ§€λŠ” 폴백 체인은 μ‹€μ‚¬μš©μ—μ„œ 데이터 λˆ„λ½μ„ μ΅œμ†Œν™”ν•œλ‹€. `_safe_float()` 일관 μ‚¬μš©λ„ μ’‹λ‹€. + +**4. 10λ…„ 2단계 DCF** +5λ…„ DCF의 터미널밸λ₯˜ μ™œκ³‘ 문제λ₯Ό μΈμ‹ν•˜κ³  Y6~10 μ„ ν˜• Fadeλ₯Ό μ μš©ν•œ 건 학뢀생 μˆ˜μ€€μ„ λ„˜μ–΄μ„ λ‹€. Damodaran μ°Έμ‘° νŒ¨λ„κΉŒμ§€ μžˆμ–΄μ„œ ν•™μˆ μ  근거도 ν™•λ³΄ν–ˆλ‹€. + +**5. Piotroski F-Score, Altman Z, DuPont β€” μ •λŸ‰ 깊이** +λ‹¨μˆœ λ°Έλ₯˜μ—μ΄μ…˜μ΄ μ•„λ‹Œ 재무 건전성 μ§€ν‘œκΉŒμ§€ μ»€λ²„ν•œ 점이 Bloomberg Terminal 컨셉과 λ§žλ‹€. + +### 🟑 κ°œμ„ μ΄ ν•„μš”ν•œ 점 (Weaknesses) + +**1. app.py 단일 파일 2,846쀄 β€” κ°€μž₯ 큰 기술 뢀채** +SEC νŒŒμ‹±, Gemini 호좜, DCF 계산, 차트 생성, UI λ Œλ”λ§μ΄ μ „λΆ€ ν•˜λ‚˜μ— μžˆλ‹€. 디버깅, ν…ŒμŠ€νŠΈ, ν˜‘μ—… λͺ¨λ‘ μ–΄λ ΅λ‹€. λͺ¨λ“ˆ 뢄리가 μ΅œμš°μ„ μ΄λ‹€. + +**2. Streamlit ν•œκ³„ β€” Bloomberg 터미널 UI와 거리가 μžˆλ‹€** +Streamlit은 ν”„λ‘œν† νƒ€μ΄ν•‘μ—” μ΅œκ³ μ§€λ§Œ, 닀쀑 νŒ¨λ„ λ™μ‹œ μ—…λ°μ΄νŠΈ, μ‹€μ‹œκ°„ μ›Ήμ†ŒμΌ“, μ»€μŠ€ν…€ λ ˆμ΄μ•„μ›ƒμ— μ œμ•½μ΄ 크닀. Next.js + FastAPI둜 μ „ν™˜ν•˜λ©΄ μ§„μ •ν•œ 터미널 UXκ°€ κ°€λŠ₯ν•˜λ‹€. + +**3. κΈ€λ‘œλ²Œ μ‹œμž₯ 컀버리지 β€” 아직 US 쀑심** +ν•œκ΅­(DART), 일본(EDINET), 유럽, 쀑ꡭ/홍콩 κ³΅μ‹œ νŒŒμ‹±μ΄ "Phase 2" μƒνƒœλ‹€. 가격 λ°μ΄ν„°λŠ” yfinance μ ‘λ―Έμ‚¬λ‘œ μ»€λ²„λ˜μ§€λ§Œ, κ³΅μ‹œ 뢄석은 미ꡭ만 λœλ‹€. + +**4. 포트폴리였 β€” μŠ€ν¬λ¦°μƒ· OCR은 μžˆμ§€λ§Œ 지속성 λΆ€μ‘±** +Trading 212, IBKR μŠ€ν¬λ¦°μƒ· 뢄석은 κ΅¬ν˜„λ˜μ—ˆμœΌλ‚˜, ν¬μ§€μ…˜ 이λ ₯ 좔적, 수읡λ₯  μ‹œκ³„μ—΄, λ¦¬λ°ΈλŸ°μ‹± μΈμ‚¬μ΄νŠΈκ°€ μ—†λ‹€. Supabase에 μ €μž₯ν•˜λ©΄ ν•΄κ²°λœλ‹€. + +**5. λ‰΄μŠ€ 톡합 μ—†μŒ** +νšŒμ‚¬ κ΄€λ ¨ λ‰΄μŠ€κ°€ μ•„μ˜ˆ μ—†λ‹€. Bloomberg ν„°λ―Έλ„μ˜ 핡심 κΈ°λŠ₯ 쀑 ν•˜λ‚˜μΈ λ‰΄μŠ€ ν”Όλ“œκ°€ λΉ μ Έ μžˆλ‹€. + +**6. ν™˜μœ¨/μ•”ν˜Έν™”ν β€” 별도 νƒ­μ΄μ§€λ§Œ 메인 λŒ€μ‹œλ³΄λ“œμ™€ 톡합 λΆ€μ‘±** +Bithumb/Binance 가격은 μžˆμ§€λ§Œ, 포트폴리였 총 수읡λ₯ μ— FX 영ν–₯이 μ‹€μ‹œκ°„μœΌλ‘œ λ°˜μ˜λ˜λŠ” 톡합 λ·°κ°€ μ—†λ‹€. + +### πŸ”΄ μœ„ν—˜ μš”μ†Œ (Risks) + +- `app.py` 3000쀄 단일 νŒŒμΌμ€ 더 컀지면 μœ μ§€λ³΄μˆ˜ λΆˆκ°€λŠ₯ν•΄μ§„λ‹€ +- yfinance/yahooqueryλŠ” μ–Έμ œλ“  차단될 수 μžˆλ‹€ (IP μ œν•œ) +- Gemini 무료 ν‹°μ–΄ rate limit은 ν”„λ‘œλ•μ…˜μ—μ„œ λ¬Έμ œκ°€ λœλ‹€ +- SEC EDGAR User-Agent μ •μ±… μœ„λ°˜ μ‹œ IP 차단 κ°€λŠ₯ + +--- + +## PART 2: λ°œμ „ λ°©ν–₯ β€” Next.js 기반 ATLAS Terminal + +### λͺ©ν‘œ μ•„ν‚€ν…μ²˜ + +``` +atlas-terminal/ +β”œβ”€β”€ apps/ +β”‚ └── web/ # Next.js 14 (App Router) +β”‚ β”œβ”€β”€ app/ +β”‚ β”‚ β”œβ”€β”€ layout.tsx # 루트 λ ˆμ΄μ•„μ›ƒ (닀크 ν…Œλ§ˆ, κΈ€λ‘œλ²Œ λ„€λΉ„) +β”‚ β”‚ β”œβ”€β”€ page.tsx # 메인 λŒ€μ‹œλ³΄λ“œ (κ·Έλ¦¬λ“œ λ ˆμ΄μ•„μ›ƒ) +β”‚ β”‚ β”œβ”€β”€ (dashboard)/ +β”‚ β”‚ β”‚ β”œβ”€β”€ overview/ # 포트폴리였 μ˜€λ²„λ·° + λ‰΄μŠ€ ν”Όλ“œ +β”‚ β”‚ β”‚ β”œβ”€β”€ research/ # 10-K 뢄석 + λ‰΄μŠ€ (νšŒμ‚¬λ³„) +β”‚ β”‚ β”‚ β”œβ”€β”€ valuation/ # DCF + RIM + Comps +β”‚ β”‚ β”‚ β”œβ”€β”€ markets/ # 히트맡 + ν™˜μœ¨ + μ•”ν˜Έν™”ν +β”‚ β”‚ β”‚ β”œβ”€β”€ portfolio/ # ν¬μ§€μ…˜ 관리 + μŠ€ν¬λ¦°μƒ· OCR +β”‚ β”‚ β”‚ └── filings/ # SEC/DART/EDINET 원문 λ·°μ–΄ +β”‚ β”‚ └── api/ # Route Handlers (BFF νŒ¨ν„΄) +β”‚ β”‚ β”œβ”€β”€ search/ +β”‚ β”‚ β”œβ”€β”€ news/ +β”‚ β”‚ └── portfolio/ +β”‚ β”œβ”€β”€ components/ +β”‚ β”‚ β”œβ”€β”€ ui/ # shadcn/ui 기반 μ›μž μ»΄ν¬λ„ŒνŠΈ +β”‚ β”‚ β”œβ”€β”€ charts/ # Recharts/D3 차트 μ»΄ν¬λ„ŒνŠΈ +β”‚ β”‚ β”‚ β”œβ”€β”€ SankeyFlow.tsx +β”‚ β”‚ β”‚ β”œβ”€β”€ RadarHealth.tsx +β”‚ β”‚ β”‚ β”œβ”€β”€ DCFWaterfall.tsx +β”‚ β”‚ β”‚ └── HeatmapGrid.tsx +β”‚ β”‚ β”œβ”€β”€ terminal/ # 터미널 μŠ€νƒ€μΌ μ»΄ν¬λ„ŒνŠΈ +β”‚ β”‚ β”‚ β”œβ”€β”€ TickerBar.tsx # 상단 μ‹€μ‹œκ°„ 티컀 λ°” +β”‚ β”‚ β”‚ β”œβ”€β”€ CommandPalette.tsx # ⌘K 검색 +β”‚ β”‚ β”‚ β”œβ”€β”€ PanelGrid.tsx # λ¦¬μ‚¬μ΄μ¦ˆ κ°€λŠ₯ νŒ¨λ„ +β”‚ β”‚ β”‚ └── NewsFeed.tsx # μ‹€μ‹œκ°„ λ‰΄μŠ€ ν”Όλ“œ +β”‚ β”‚ └── portfolio/ +β”‚ β”‚ β”œβ”€β”€ ScreenshotUpload.tsx +β”‚ β”‚ └── PositionTable.tsx +β”‚ β”œβ”€β”€ lib/ +β”‚ β”‚ β”œβ”€β”€ api-client.ts # FastAPI 호좜 래퍼 +β”‚ β”‚ └── format.ts # 톡화/숫자 포맷 μœ ν‹Έ +β”‚ └── styles/ +β”‚ └── terminal-theme.css # Bloomberg μŠ€νƒ€μΌ CSS λ³€μˆ˜ +β”‚ +β”œβ”€β”€ server/ # FastAPI λ°±μ—”λ“œ +β”‚ β”œβ”€β”€ main.py # FastAPI μ—”νŠΈλ¦¬ν¬μΈνŠΈ +β”‚ β”œβ”€β”€ routers/ +β”‚ β”‚ β”œβ”€β”€ edgar.py # SEC EDGAR λ‹€μš΄λ‘œλ“œ/νŒŒμ‹± +β”‚ β”‚ β”œβ”€β”€ dart.py # ν•œκ΅­ DART API +β”‚ β”‚ β”œβ”€β”€ edinet.py # 일본 EDINET API +β”‚ β”‚ β”œβ”€β”€ analysis.py # Gemini LLM 뢄석 (MD&A, Risk) +β”‚ β”‚ β”œβ”€β”€ valuation.py # DCF, RIM, Comps 계산 +β”‚ β”‚ β”œβ”€β”€ market_data.py # yfinance/yahooquery 래퍼 +β”‚ β”‚ β”œβ”€β”€ news.py # λ‰΄μŠ€ 집계 (RSS + API) +β”‚ β”‚ β”œβ”€β”€ crypto.py # Bithumb/Binance API +β”‚ β”‚ β”œβ”€β”€ fx.py # ν™˜μœ¨ 데이터 +β”‚ β”‚ └── portfolio.py # 포트폴리였 CRUD + OCR +β”‚ β”œβ”€β”€ services/ +β”‚ β”‚ β”œβ”€β”€ gemini_service.py # Gemini API 래퍼 (retry, chunk, stream) +β”‚ β”‚ β”œβ”€β”€ sec_parser.py # 10-K HTML β†’ μ„Ήμ…˜ μΆ”μΆœ +β”‚ β”‚ β”œβ”€β”€ text_chunker.py # smart_chunk, clean_text_for_llm +β”‚ β”‚ β”œβ”€β”€ dcf_engine.py # 10Y 2-stage DCF + Reverse DCF + RIM +β”‚ β”‚ β”œβ”€β”€ financial_metrics.py # DuPont, Altman Z, F-Score, Red Flags +β”‚ β”‚ β”œβ”€β”€ market_fetcher.py # yfinance/yahooquery 폴백 체인 +β”‚ β”‚ └── screenshot_ocr.py # Gemini Vision 포트폴리였 OCR +β”‚ β”œβ”€β”€ models/ +β”‚ β”‚ β”œβ”€β”€ schemas.py # Pydantic μš”μ²­/응닡 μŠ€ν‚€λ§ˆ +β”‚ β”‚ └── db.py # Supabase ν΄λΌμ΄μ–ΈνŠΈ +β”‚ └── utils/ +β”‚ β”œβ”€β”€ safe_float.py # _safe_float, _na μœ ν‹Έ +β”‚ β”œβ”€β”€ ticker_utils.py # get_global_ticker, infer_market +β”‚ └── cache.py # Redis/인메λͺ¨λ¦¬ μΊμ‹œ 래퍼 +β”‚ +β”œβ”€β”€ supabase/ +β”‚ └── migrations/ # DB μŠ€ν‚€λ§ˆ (포트폴리였, μΊμ‹œ, μ‚¬μš©μž) +β”‚ +β”œβ”€β”€ claude.md # ← 이 파일 (μ—μ΄μ „νŠΈ λΉŒλ”© κ°€μ΄λ“œ) +β”œβ”€β”€ .github/ +β”‚ └── workflows/ +β”‚ └── update-readme.yml # README μžλ™ μ—…λ°μ΄νŠΈ (GitHub Actions) +└── package.json +``` + +### UI λ””μžμΈ λ°©ν–₯ β€” "Terminal Noir" + +Bloomberg Terminal + Notion의 깔끔함 + 닀크 λͺ¨λ“œλ₯Ό κ²°ν•©ν•œ λ””μžμΈ + +**컬러 νŒ”λ ˆνŠΈ:** +- Background: `#0A0A0F` (거의 κ²€μ •, 살짝 넀이비) +- Surface: `#12121A` (μΉ΄λ“œ/νŒ¨λ„ λ°°κ²½) +- Border: `#1E1E2E` (ꡬ뢄선) +- Primary: `#00D4AA` (민트 κ·Έλ¦° β€” 핡심 μ•‘μ…˜, μƒμŠΉ) +- Danger: `#FF4757` (ν•˜λ½, κ²½κ³ ) +- Text Primary: `#E8E8F0` (거의 흰색) +- Text Secondary: `#6B7280` (μ„€λͺ… ν…μŠ€νŠΈ) +- Accent: `#818CF8` (인디고 β€” AI 뢄석 κ²°κ³Ό κ°•μ‘°) + +**νƒ€μ΄ν¬κ·Έλž˜ν”Ό:** +- 숫자/데이터: `JetBrains Mono` (λͺ¨λ…ΈμŠ€νŽ˜μ΄μŠ€, 가독성) +- 제λͺ©: `Satoshi` (κΈ°ν•˜ν•™μ  산세리프, λͺ¨λ˜) +- λ³Έλ¬Έ: `Inter` (가독성 μ΅œμš°μ„ ) + +**핡심 UI νŒ¨ν„΄:** +1. **Command Palette (⌘K)**: νšŒμ‚¬ 검색, κΈ°λŠ₯ 이동 β€” Notion/Linear μŠ€νƒ€μΌ +2. **λ¦¬μ‚¬μ΄μ¦ˆ κ°€λŠ₯ νŒ¨λ„ κ·Έλ¦¬λ“œ**: react-grid-layout으둜 μ‚¬μš©μžκ°€ νŒ¨λ„ 배치 μ»€μŠ€ν…€ +3. **상단 티컀 λ°”**: μ‹€μ‹œκ°„ 가격 슀크둀 (주식 + μ•”ν˜Έν™”ν + ν™˜μœ¨) +4. **μ‚¬μ΄λ“œλ°” μ›ŒμΉ˜λ¦¬μŠ€νŠΈ**: 즐겨찾기 μ’…λͺ© μ‹€μ‹œκ°„ μ—…λ°μ΄νŠΈ +5. **AI 뢄석 κ²°κ³Ό**: 인디고 보더 μΉ΄λ“œ μ•ˆμ— 슀트리밍 ν…μŠ€νŠΈ + +--- + +## PART 3: claude.md (Claude Code λΉŒλ”© κ°€μ΄λ“œ) + +μ•„λž˜ λ‚΄μš©μ„ ν”„λ‘œμ νŠΈ 루트의 `claude.md`둜 μ €μž₯ν•˜λ©΄ Claude Codeκ°€ μ°Έμ‘°ν•œλ‹€. + +--- diff --git a/PROJECT_ANALYSIS.md b/PROJECT_ANALYSIS.md deleted file mode 100644 index 7593a28..0000000 --- a/PROJECT_ANALYSIS.md +++ /dev/null @@ -1,223 +0,0 @@ -# FQDC ν”„λ‘œμ νŠΈ 심측 뢄석 λ³΄κ³ μ„œ - -> All-in-One Financial Analysis Dashboard β€” μ •μ„±Β·μ •λŸ‰ ν•˜μ΄λΈŒλ¦¬λ“œ 금육 뢄석 λŒ€μ‹œλ³΄λ“œ - ---- - -## 1. ν”„λ‘œμ νŠΈ 정체성 및 λΉ„μ „ - -### 1.1 λͺ©μ  -- **문제:** 주식 λ¦¬μ„œμΉ˜μ˜ λΆ„μ‚° β€” 200νŽ˜μ΄μ§€κΈ‰ 10-K κ³΅μ‹œ, 별도 μŠ€ν”„λ ˆλ“œμ‹œνŠΈ DCF, μ—¬λŸ¬ 도ꡬ에 흩어진 동쒅사 비ꡐ. -- **ν•΄κ²°:** **단일 μ›Œν¬ν”Œλ‘œμš°**μ—μ„œ κ³΅μ‹œ β†’ μΈμ‚¬μ΄νŠΈ β†’ λ°Έλ₯˜μ—μ΄μ…˜ β†’ 동쒅사 λΉ„κ΅κΉŒμ§€ ν•œ λ²ˆμ— μˆ˜ν–‰. - -### 1.2 νƒ€κ²Ÿ -- **B2C:** 개인 투자자 β€” κΈ°κ΄€ μˆ˜μ€€ ꡬ쑰λ₯Ό λ‹¨μˆœν•œ UI둜 제곡. -- **B2B:** μ• λ„λ¦¬μŠ€νŠΈ, PM, Corp Dev β€” κ³΅μ‹œ β†’ μΈμ‚¬μ΄νŠΈ β†’ λ°Έλ₯˜λ₯Ό ν•œ νλ¦„μœΌλ‘œ 처리. - -### 1.3 λ‹¨μœ„ 경제 -- **μ •μ„±:** Gemini **1회 호좜** (Item 7 λ˜λŠ” 1Aλ‹Ή) β†’ 토큰 λΉ„μš© μ΅œμ†Œν™”. -- **μ •λŸ‰:** yfinance / yahooquery **무료** β†’ 숫자 정확도 확보, API λΉ„μš© μ—†μŒ. - ---- - -## 2. μ•„ν‚€ν…μ²˜ κ°œμš” - -### 2.1 ν•˜μ΄λΈŒλ¦¬λ“œ 뢄리 원칙 -| νŒŒμ΄ν”„λΌμΈ | λ‹΄λ‹Ή | 데이터 μ†ŒμŠ€ | λΉ„κ³  | -|-----------|------|-------------|------| -| **μ •μ„± (Qualitative)** | LLM (Gemini 2.0 Flash) | SEC EDGAR 10-K (Item 1A, 3, 7, 9A) | 토큰 μ ˆκ°μ„ μœ„ν•΄ Item 7Β·1A만 전솑 | -| **μ •λŸ‰ (Quantitative)** | Pandas + yfinance/yahooquery | Yahoo Finance API | TTM/λΆ„κΈ° 폴백, 닀쀑 속성 폴백 | - -### 2.2 μ•„ν‚€ν…μ²˜ λ‹€μ΄μ–΄κ·Έλž¨ (μš”μ•½) -``` -User β†’ Streamlit UI (3 Tabs) - β”œβ”€ Tab1: 10-K & MD&A Insights - β”‚ β”œβ”€ Qual: SEC 10-K β†’ Parser β†’ Item 1A/7 β†’ Gemini β†’ μ „λž΅Β·λ¦¬μŠ€ν¬ 리포트 - β”‚ └─ Quant: yfinance β†’ DuPont, Altman Z, Sankey, Radar, F-Score, YoY, Red Flags - β”œβ”€ Tab2: DCF Valuation - β”‚ └─ Quant: yfinance β†’ FCF/Debt/Cash/Shares β†’ 10Y 2-Stage DCF, Bull/Base/Bear - └─ Tab3: Industry Analysis - β”œβ”€ Quant: yfinance β†’ Peer P/E, EV/EBITDA, P/B (쑰건뢀 ν¬λ§·νŒ…) - └─ Qual: Gemini β†’ Industry Outlook (κ±°μ‹œ νŠΈλ Œλ“œ) -``` - -### 2.3 μ£Όμš” μ•„ν‚€ν…μ²˜ κ²°μ • (ADL) -- **ADL-001:** 429 λŒ€μ‘ β€” Item 7Β·1A만 μΆ”μΆœ, Item 8 μˆ«μžλŠ” yfinance둜 λŒ€μ²΄ β†’ 토큰 80%+ 절감. -- **ADL-002:** 10λ…„ 2단계 DCF β€” 5λ…„ DCF의 터미널 κ°€μΉ˜ μ™œκ³‘ μ™„ν™”; Y1–5 μ„±μž₯, Y6–10 Fade ν›„ TV. -- **ADL-003:** 닀쀑 폴백 β€” yahooquery 1μˆœμœ„ β†’ yfinance (fast_info β†’ info β†’ balance_sheet) β†’ TTM/λΆ„κΈ° ν•©μ‚°. - ---- - -## 3. 기술 μŠ€νƒ 및 인프라 - -### 3.1 λŸ°νƒ€μž„ -- **Python:** 3.9+ -- **ν”„λ ˆμž„μ›Œν¬:** Streamlit β‰₯ 1.28.0 - -### 3.2 μ™ΈλΆ€ API -| API | 라이브러리 | μš©λ„ | 인증 | -|-----|-----------|------|------| -| Google Gemini | google-generativeai β‰₯ 0.8.0 | MD&A μΈμ‚¬μ΄νŠΈ, 리슀크, μ‚°μ—… 전망 | GOOGLE_API_KEY | -| SEC EDGAR | sec-edgar-downloader β‰₯ 5.0.0 | μ΅œμ‹  10-K HTML λ‹€μš΄λ‘œλ“œ | SEC_EDGAR_EMAIL (User-Agent) | -| Yahoo Finance | yfinance β‰₯ 0.2.40, yahooquery β‰₯ 2.2.0 | 검색, μž¬λ¬΄μ œν‘œ, λ©€ν‹°ν”Œ, μ»¨μ„Όμ„œμŠ€ | μ—†μŒ | - -### 3.3 데이터 처리 -- **Pandas** β‰₯ 2.0.0 β€” 재무 μ‹œκ³„μ—΄, DCF, comps ν…Œμ΄λΈ”. -- **BeautifulSoup4** β‰₯ 4.12.0, **lxml** β‰₯ 4.9.0 β€” HTML νŒŒμ‹±Β·ν΄λ Œμ§•. -- **Plotly** β‰₯ 5.18.0 β€” Sankey, Radar, 5λ…„ νŠΈλ Œλ“œ 라인 차트. - ---- - -## 4. 데이터 흐름 상세 - -### 4.1 μ •μ„± 데이터 흐름 (Qualitative Flow) -1. **λ‹€μš΄λ‘œλ“œ:** `sec-edgar-downloader`둜 티컀당 μ΅œμ‹  10-K HTML (μž„μ‹œ 디렉터리). -2. **μŠ¬λΌμ΄μ‹±:** `_slice_html_items_1a_to_9a()` β€” μ›λ¬Έμ—μ„œ Item 1A ~ Item 9A κ΅¬κ°„λ§Œ λ¬Έμžμ—΄λ‘œ μΆ”μΆœ (λŒ€μš©λŸ‰ 파일 νšŒν”Ό). -3. **νŒŒμ‹±Β·ν΄λ Œμ§•:** `extract_text_from_html()` β†’ BeautifulSoup(lxml)으둜 table/img/script 제거 β†’ `find_item_section_generic()`둜 Item 1A, 3, 7, 8, 9A μΆ”μΆœ β†’ `clean_text_for_llm()` (νƒœκ·ΈΒ·κ³΅λ°± 정리). -4. **캐싱:** `data/{TICKER}_latest.json`에 item1a, item3, item7, item8, item9a μ €μž₯. μž¬μ‹€ν–‰ μ‹œ λ‹€μš΄λ‘œλ“œ μƒλž΅. -5. **Chunking:** `smart_chunk(section, max_chars=10000)` β€” κΈ΄ Item 7은 μ•žΒ·λ’€ λΉ„μœ¨λ‘œ μ••μΆ•. -6. **LLM:** Gemini `generate_content` (λ˜λŠ” `stream=True`) β€” μ „λž΅(Item 7), 리슀크(Item 1A), ν¬λ Œμ‹(Item 3Β·9A). 429 μ‹œ `_generate_with_retry()`둜 60초 λŒ€κΈ° ν›„ μž¬μ‹œλ„. - -### 4.2 μ •λŸ‰ 데이터 흐름 (Quantitative Flow) -1. **검색:** μ‚¬μ΄λ“œλ°” β€” yahooquery `search(query)` β†’ EQUITY/ETF만 ν•„ν„°, INDEX/MUTUALFUND μ œμ™Έ β†’ `[Exchange] Symbol - Name` 선택. -2. **티컀 μ •κ·œν™”:** `get_global_ticker(ticker, market)` β€” US κ·ΈλŒ€λ‘œ, ν•œκ΅­ .KS/.KQ, 일본 .T, UK .L. -3. **재무 데이터:** `_get_annual_financials_balance_cashflow(ticker)` β€” yahooquery 1μˆœμœ„, μ‹€νŒ¨ μ‹œ yfinance annual β†’ λΉ„μ–΄ 있으면 quarterly(TTM) 폴백. -4. **DCF μž…λ ₯:** `get_dcf_inputs()` β€” FCF=OCFβˆ’CapEx, Shares (fast_info β†’ info β†’ balance_sheet), Debt/Cash 동일 닀단계 폴백. -5. **μ‹œκ°ν™”Β·μ§€ν‘œ:** DuPont, Altman Z, Piotroski F-Score, Sankey, Radar, YoY, Red Flags β€” λͺ¨λ‘ `_safe_float()` 및 N/A 처리. - ---- - -## 5. μ½”λ“œ ꡬ쑰 - -### 5.1 파일 λ ˆμ΄μ•„μ›ƒ -``` -FQDC Project/ -β”œβ”€β”€ app.py # 메인 μ•± (~3039쀄): UI, SEC, Gemini, DCF, Comps, 차트 -β”œβ”€β”€ find_toc.py # 10-K HTML λͺ©μ°¨(TOC) 탐색 μœ ν‹Έ β€” CLI λ˜λŠ” URL 인자 -β”œβ”€β”€ push_to_github.sh # 원격 ν‘Έμ‹œ 슀크립트 -β”œβ”€β”€ requirements.txt # μ˜μ‘΄μ„± -β”œβ”€β”€ .env.example # GOOGLE_API_KEY, SEC_EDGAR_EMAIL ν…œν”Œλ¦Ώ -β”œβ”€β”€ .gitignore # venv, .env, .app_prefs.json, data/ -β”œβ”€β”€ data/ # λŸ°νƒ€μž„: 10-K JSON μΊμ‹œ (티컀별) -β”œβ”€β”€ .app_prefs.json # λŸ°νƒ€μž„: API 킀·이메일 둜컬 μ €μž₯ (선택) -β”œβ”€β”€ .agent/ # μ—μ΄μ „νŠΈ/λ¬Έμ„œ -β”‚ β”œβ”€β”€ prd.md # μ œν’ˆ μš”κ΅¬μ‚¬ν•­ -β”‚ β”œβ”€β”€ architecture.mermaid -β”‚ β”œβ”€β”€ flows.md # μ •μ„±/μ •λŸ‰ 흐름 μ„€λͺ… -β”‚ β”œβ”€β”€ directory_map.md -β”‚ β”œβ”€β”€ adl.yaml # μ•„ν‚€ν…μ²˜ κ²°μ • -β”‚ β”œβ”€β”€ infra.yaml # μΈν”„λΌΒ·μ˜μ‘΄μ„± -β”‚ β”œβ”€β”€ manifest.json # 메타데이터 -β”‚ └── rules.md # 개발 κ°€λ“œλ ˆμΌ -β”œβ”€β”€ TECHNICAL_NOTES.md # 429 λŒ€μ‘Β·ν† ν° μ΅œμ ν™” 기술 λ…ΈνŠΈ -β”œβ”€β”€ README.md -└── AGENT.md # μ—μ΄μ „νŠΈ μ§„μž…μ  -``` - -### 5.2 app.py ν•¨μˆ˜ κ·Έλ£Ή (μš”μ•½) -| ꡬ간 | μ—­ν•  | λŒ€ν‘œ ν•¨μˆ˜ | -|------|------|-----------| -| μ„€μ •Β·μœ ν‹Έ | ν”„λ¦¬νΌλŸ°μŠ€, 경둜 | `_load_prefs`, `_save_prefs`, `_PREFS_PATH`, `_DATA_DIR` | -| ν‹°μ»€Β·μ‹œμž₯ | 검색·접미사 | `get_global_ticker`, `infer_market_from_ticker`, `SECTORS` | -| SECΒ·10-K | λ‹€μš΄λ‘œλ“œΒ·νŒŒμ‹±Β·μΊμ‹œ | `_slice_html_items_1a_to_9a`, `extract_text_from_html`, `find_item_section_generic`, `download_and_extract_all_items`, `get_10k_sections`, `_load_10k_from_cache`, `_save_10k_to_cache` | -| Gemini | λͺ¨λΈΒ·μž¬μ‹œλ„Β·μŠ€νŠΈλ¦¬λ° | `get_gemini_model`, `_generate_with_retry`, `_generate_stream`, `get_gemini_item7_strategy`, `get_gemini_item7_strategy_stream`, `get_gemini_item1a_risks`, `get_gemini_item1a_risks_stream`, `_gemini_forensic_audit`, `get_sec_financials_llm`, `get_industry_outlook` | -| μ •λŸ‰Β·μž¬λ¬΄ | yfinance/yyahooquery | `_get_annual_financials_balance_cashflow`, `_get_row_series`, `get_dcf_inputs`, `get_dcf_smart_defaults`, `get_analyst_consensus`, `get_sector_industry`, `get_5yr_financial_trend` | -| DCF | 10λ…„ 2단계 | `dcf_10y_2stage`, `excel_style_dcf`, `_damodaran_wacc_for_sector` | -| μ§€ν‘œΒ·μ°¨νŠΈ | DuPont, Altman, F-Score, Sankey, Radar | `get_dupont_altman_redflags_yoy`, `get_piotroski_fscore`, `get_income_statement_sankey_data`, `_build_sankey_figure`, `_build_radar_figure`, `get_sector_specific_metrics` | -| Comps | 동쒅사 λ©€ν‹°ν”Œ | `get_comps_data` | -| UI | νƒ­Β·μ‚¬μ΄λ“œλ°” | 2305쀄~ `st.tabs`, Tab1/2/3 블둝, μ‚¬μ΄λ“œλ°” 검색·선택 | - -### 5.3 캐싱 μ „λž΅ -- **Streamlit:** `@st.cache_data(ttl=300)` (5λΆ„) β€” `get_dcf_inputs`, `get_analyst_consensus`, `get_dupont_altman_redflags_yoy`, `get_comps_data`, `get_5yr_financial_trend`, `get_sector_industry`, `get_radar_metrics_normalized`, `get_piotroski_fscore`, `get_sector_specific_metrics`, `_get_annual_financials_balance_cashflow` λ“±. -- **Item8 LLM:** `@st.cache_data(ttl=3600)` β€” `get_sec_financials_llm` (1μ‹œκ°„). -- **10-K λ³Έλ¬Έ:** `data/{ticker}_latest.json` β€” μΊμ‹œ 쑴재 μ‹œ `get_10k_sections()`μ—μ„œ λ‹€μš΄λ‘œλ“œΒ·νŒŒμ‹± μƒλž΅. -- **μƒνƒœ:** `st.session_state` β€” ticker, market, google_api_key, sec_email, mda_strategy_result, mda_risk_result, company_search_options λ“±. - ---- - -## 6. 탭별 κΈ°λŠ₯ 상세 - -### 6.1 Tab 1 β€” 10-K & MD&A Insights -- **상단:** Sector/Industry 뱃지 (get_sector_industry). -- **Financial Health:** - - Sankey: 손읡 흐름 (Item 8 LLM μΆ”μΆœ λ˜λŠ” yfinance). - - Radar: ROE, Current Ratio, Asset Turnover, Equity Mult., Revenue YoY (μ •κ·œν™”). - - Piotroski F-Score (9점), Altman Z-Score, Red Flags (Current Ratio < 1.0, Interest Coverage < 1.5). - - Sector-specific: Tech(Rule of 40, FCF margin, R&D%), Retail(μž¬κ³ νšŒμ „μœ¨, μ˜μ—…μ΄μ΅λ₯ ), Financials(ROE, ROA). - - YoY λΉ„μœ¨ λ³€ν™”, 뢄기별 λͺ¨λ©˜ν…€ ν…Œμ΄λΈ” (녹색/빨간색 쑰건뢀 μŠ€νƒ€μΌ). -- **Deep-Dive (AI):** - - US ν•œμ •: "Analyze Management Strategy (MD&A)" β†’ Item 7 슀트리밍; "Analyze Risk Factors (Item 1A)" β†’ Item 1A 슀트리밍 + Item 3Β·9A ν¬λ Œμ‹. - - ν•œκ΅­/일본/UK: ν˜„μž¬ κ²½κ³  λ©”μ‹œμ§€ (DART/EDINET/LSE Phase 2 μ˜ˆμ •). - - κ²°κ³ΌλŠ” session_state에 μ €μž₯ ν›„ expander둜 이전 뢄석 ν‘œμ‹œ. - -### 6.2 Tab 2 β€” 3-Scenario DCF Valuation -- **5λ…„ νŠΈλ Œλ“œ:** Revenue, Net Income, Operating Margin %, FCF (YoY), Plotly 라인 차트. -- **DCF μž…λ ₯:** Base FCF (OCFβˆ’CapEx μžλ™ λ˜λŠ” μˆ˜λ™), Shares/Debt/Cash (μžλ™ 닀단계 폴백, μ‹€νŒ¨ μ‹œλ§Œ μˆ˜λ™). -- **μŠ¬λΌμ΄λ”:** WACC, Terminal Growth (κΈ°λ³Έ 2.5%), Projected FCF Growth (Y1–5). κΈ°λ³Έκ°’: `get_dcf_smart_defaults()` β€” Beta(CAPM), revenueGrowth/earningsGrowth 기반. -- **Reference νŒ¨λ„:** μ• λ„λ¦¬μŠ€νŠΈ μ»¨μ„Όμ„œμŠ€ (λͺ©ν‘œκ°€, μΆ”μ²œ, μ„±μž₯λ₯ ) + Damodaran (μ„Ήν„° WACC, ERP 4.6%, Rf 4.2%, 링크). -- **좜λ ₯:** Bull/Base/Bear (FCF μ„±μž₯λ₯  Β±2%) λ‚΄μž¬κ°€μΉ˜, ν˜„μž¬κ°€ λŒ€λΉ„, 3μ‹œλ‚˜λ¦¬μ˜€ ν…Œμ΄λΈ”. - -### 6.3 Tab 3 β€” Industry Analysis & Comps -- **μ‚°μ—… 선택:** SECTORS (Semiconductors & Hardware, Software & Cloud, Consumer Retail, Financial Services, Healthcare) β†’ ν•΄λ‹Ή μ‚°μ—… Top 5 티컀. -- **Comps ν…Œμ΄λΈ”:** Forward P/E, EV/EBITDA, P/B β€” μ΅œμ†Œκ°’ 녹색, μ΅œλŒ€κ°’ 빨간색 쑰건뢀 포맷; N/A 처리. -- **AI Industry Outlook:** Gemini둜 12–18κ°œμ›” κ±°μ‹œ νŠΈλ Œλ“œ, μ„±μž₯ 동λ ₯, 리슀크 μš”μ•½. - ---- - -## 7. 핡심 μ•Œκ³ λ¦¬μ¦˜ - -### 7.1 10λ…„ 2단계 DCF (`dcf_10y_2stage`) -- **Stage 1 (Y1–5):** FCFκ°€ λ§€λ…„ `fcf_growth`둜 μ„±μž₯. -- **Stage 2 (Y6–10):** μ„±μž₯λ₯ μ΄ `fcf_growth`μ—μ„œ `term_growth`κΉŒμ§€ μ„ ν˜• Fade (`fade = (t-6)/4`). -- **Terminal Value:** Y10 FCF Γ— (1 + term_growth) / (wacc - term_growth), Y10 μ‹œμ μœΌλ‘œ 할인. -- **Equity:** EV βˆ’ Total Debt + Cash; μ£Όλ‹Ήκ°€μΉ˜ = Equity / Shares. - -### 7.2 DuPont 3단계 -- ROE = NPM Γ— Asset Turnover Γ— Equity Multiplier (연도별). -- NPM = Net Income / Revenue, AT = Revenue / Total Assets, EM = Total Assets / Equity. - -### 7.3 Altman Z-Score -- Z = 1.2Γ—(WC/TA) + 1.4Γ—(RE/TA) + 3.3Γ—(EBIT/TA) + 0.6Γ—(MC/TL) + 1.0Γ—(Sales/TA). Safe > 2.99, Distress < 1.81. - -### 7.4 Smart Defaults (DCF) -- WACC: Beta(κΈ°λ³Έ 1.0), Rf 4%, MRP 5% β†’ CAPM 근사. -- Terminal: 2.5% (Damodaran μŠ€νƒ€μΌ). -- FCF Growth: revenueGrowth λ˜λŠ” earningsGrowth (예: 0.15 β†’ 15%); μ—†μœΌλ©΄ 8%. - ---- - -## 8. μ—λŸ¬ 처리 및 개발 κ·œμΉ™ (rules.md) - -- **μ˜ˆμ™ΈΒ·ν΄λ°±:** λͺ¨λ“  금육 API 호좜 try/except; μ‹€νŒ¨ μ‹œ 빈 DataFrame λ˜λŠ” 0.0/None; 수치 νŒŒμ‹±μ€ `_safe_float()` μ‚¬μš©. -- **토큰:** HTML ν΄λ Œμ§• ν›„ `smart_chunk()`; 429 μ‹œ `_generate_with_retry()` (60초 λŒ€κΈ°). -- **캐싱:** 재무/뢄석 `@st.cache_data(ttl=300)`; 10-KλŠ” `data/` JSON 영ꡬ μΊμ‹œ. -- **ν‘œμ‹œ:** NaN/None은 `_na()`둜 "N/A" 톡일. - ---- - -## 9. λ³΄μ•ˆ 및 ν™˜κ²½ - -- **λΉ„λ°€:** `.env`에 GOOGLE_API_KEY, SEC_EDGAR_EMAIL (`.gitignore`). -- **둜컬 μ €μž₯:** "Remember API key & email" 선택 μ‹œ `.app_prefs.json` (μ—­μ‹œ `.gitignore`). -- **SEC μ •μ±…:** User-Agent에 μ—°λ½μš© 이메일 ν•„μˆ˜. - ---- - -## 10. μ œν•œ 사항 및 λ‘œλ“œλ§΅ - -- **ν•œκ΅­/일본/UK:** 10-K λŒ€μ‹  DART/EDINET/LSE 연동은 "Phase 2" λ˜λŠ” "under development" μƒνƒœ; US만 전체 μ •μ„± ν”Œλ‘œμš° 지원. -- **μ‹€ν–‰ μ‹œκ°„:** Tab 1 첫 10-K λ‘œλ“œ 20–60초, Gemini 슀트리밍 5–10초; Tab 2Β·3λŠ” yfinance만으둜 수 초. -- **λ‘œλ“œλ§΅:** MVP β†’ μƒμš©ν™”(B2C/B2B SaaS) λͺ©ν‘œ. - ---- - -## 11. κ°œμ„  μ œμ•ˆ - -1. **λͺ¨λ“ˆ 뢄리:** app.pyλ₯Ό `sec_edgar.py`, `gemini_analysis.py`, `dcf.py`, `comps.py`, `charts.py`, `ui_tabs.py` λ“±μœΌλ‘œ λ‚˜λˆ„λ©΄ μœ μ§€λ³΄μˆ˜Β·ν…ŒμŠ€νŠΈ 용이. -2. **λ‹¨μœ„ ν…ŒμŠ€νŠΈ:** DCF 곡식, DuPont/Altman 계산, `_safe_float`/폴백 λ‘œμ§μ— λŒ€ν•œ pytest μΆ”κ°€. -3. **AGENT.md 경둜:** λ¬Έμ„œκ°€ `agent/`κ°€ μ•„λ‹Œ `.agent/`에 μžˆμœΌλ―€λ‘œ AGENT.md λ‚΄ 링크λ₯Ό `./.agent/`둜 ν†΅μΌν•˜κ±°λ‚˜ 디렉터리λͺ… 정리. -4. **TECHNICAL_NOTES.md:** `prefilter_after_item7` λ“± ν˜„μž¬ μ½”λ“œμ™€ λ‹€λ₯Έ ν•¨μˆ˜λͺ…이 λ¬Έμ„œμ— μžˆμ„ 수 있음 β€” μ½”λ“œ κΈ°μ€€μœΌλ‘œ λ¬Έμ„œ 동기화 ꢌμž₯. - ---- - -이 λ¬Έμ„œλŠ” ν”„λ‘œμ νŠΈ 루트의 `PROJECT_ANALYSIS.md`둜 μ €μž₯λ˜μ—ˆμœΌλ©°, μ—μ΄μ „νŠΈΒ·μ‹ κ·œ κ°œλ°œμžκ°€ 전체 ꡬ쑰와 κ·œμΉ™μ„ λΉ λ₯΄κ²Œ νŒŒμ•…ν•˜λŠ” 데 ν™œμš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€. diff --git a/TECHNICAL_NOTES.md b/TECHNICAL_NOTES.md deleted file mode 100644 index 0e80b2e..0000000 --- a/TECHNICAL_NOTES.md +++ /dev/null @@ -1,29 +0,0 @@ -# Technical Notes: 10-K Financial Analyzer - -Reference document for developers and reviewers. This describes a key architectural decision made during development. - ---- - -## Technical Challenge: Handling Large-Scale Financial Filings - -During the initial development, I encountered a **429 Resource Exhausted** error due to the massive size of 10-K filings exceeding the LLM's token quota and rate limits. - -### Consultation & Architectural Pivot - -After consulting with a senior software engineer, I re-architected the application to optimize token usage. Instead of processing the entire document, I implemented a **"Selective Section Extraction"** strategy. - -### Implemented Solution - -| Component | Description | -|-----------|-------------| -| **Targeted Parsing** | Developed a regex-based parser to isolate only critical sections: **Item 7 (MD&A)** and **Item 8 (Financial Statements)**. | -| **Token Optimization** | Integrated a **"Chunking & Filtering"** logic to remove boilerplate legal text, sending only high-signal data to the Gemini API. | -| **Efficiency** | This reduced token consumption by **over 80%**, ensuring stable performance within free-tier limits while maintaining analytical depth. | - -### Code References - -- **Section extraction**: `find_item_section()`, `ITEM7_PATTERNS`, `ITEM8_PATTERNS` in `app.py` -- **Pre-filtering**: `prefilter_after_item7()` β€” drops PART I, ITEM 1–6; only content from Item 7 onward is used -- **Smart chunking**: `smart_chunk()` β€” when a section exceeds a character limit, keeps head + tail to preserve quantitative data while cutting tokens - -These changes allow the app to stay within API rate limits without sacrificing the quality of the CFA-style analysis. diff --git a/ai/gemini_portfolio.py b/ai/gemini_portfolio.py new file mode 100644 index 0000000..e75666a --- /dev/null +++ b/ai/gemini_portfolio.py @@ -0,0 +1,87 @@ +""" +Gemini Vision β€” extract portfolio holdings from Trading 212 / IBKR screenshots. +Uses multimodal Gemini to OCR brokerage screenshots and return structured data. +""" +import json +import streamlit as st + + +def extract_portfolio_from_image(api_key: str, image_bytes: bytes, broker: str = "auto") -> list: + """ + Send a brokerage screenshot to Gemini Vision and extract holdings. + Returns list of dicts: [{"ticker": "AAPL", "name": "Apple Inc", "shares": 10, "avg_cost": 150.0}, ...] + """ + import google.generativeai as genai + from config.constants import GEMINI_MODEL + + genai.configure(api_key=api_key) + model = genai.GenerativeModel(GEMINI_MODEL) + + prompt = f"""You are a financial data extraction expert. The user has uploaded a screenshot from their **{broker}** brokerage account (Trading 212, IBKR, or similar). + +Extract ALL stock/ETF holdings visible in the screenshot. For each holding, extract: +1. **ticker** β€” the stock ticker symbol (e.g., "AAPL", "MSFT"). If only the company name is visible, infer the most likely US ticker. +2. **name** β€” the full company/ETF name as shown +3. **shares** β€” number of shares held (decimal OK) +4. **avg_cost** β€” average purchase price per share (if visible, otherwise null) +5. **current_price** β€” current market price per share (if visible, otherwise null) + +Return ONLY a valid JSON array. No explanation, no markdown. Example: +[ + {{"ticker": "AAPL", "name": "Apple Inc", "shares": 10.5, "avg_cost": 150.25, "current_price": 178.50}}, + {{"ticker": "MSFT", "name": "Microsoft Corp", "shares": 5, "avg_cost": 380.00, "current_price": 415.20}} +] + +If you cannot extract any holdings, return an empty array: [] +Important: Extract ALL visible rows, do not skip any.""" + + import PIL.Image + import io + img = PIL.Image.open(io.BytesIO(image_bytes)) + + try: + response = model.generate_content( + [prompt, img], + generation_config={"temperature": 0.1, "max_output_tokens": 4096}, + ) + text = (response.text or "").strip() + # Clean markdown code fences if present + if text.startswith("```"): + text = text.split("\n", 1)[-1] + if text.endswith("```"): + text = text.rsplit("```", 1)[0] + text = text.strip() + holdings = json.loads(text) + if not isinstance(holdings, list): + return [] + # Normalize each holding + cleaned = [] + for h in holdings: + cleaned.append({ + "ticker": str(h.get("ticker", "")).upper().strip(), + "name": str(h.get("name", "")), + "shares": _safe_num(h.get("shares")), + "avg_cost": _safe_num(h.get("avg_cost")), + "current_price": _safe_num(h.get("current_price")), + }) + return [c for c in cleaned if c["ticker"]] + except json.JSONDecodeError: + st.error("AI could not parse the screenshot. Please try a clearer image.") + return [] + except Exception as e: + err = str(e).lower() + if "429" in err or "resource" in err: + st.error("Gemini API rate limit. Please wait and retry.") + else: + st.error(f"Error extracting portfolio: {e}") + return [] + + +def _safe_num(val): + """Convert to float safely, return None on failure.""" + if val is None: + return None + try: + return float(val) + except (ValueError, TypeError): + return None diff --git a/app.py b/app.py index 93035f4..5e9bcf7 100644 --- a/app.py +++ b/app.py @@ -1,10 +1,6 @@ """ 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). -- Tab 3: Industry Comps (yfinance multiples: Forward P/E, EV/EBITDA, P/B). -- Cost-effective: Gemini only for text; all numbers from yfinance. """ import os os.environ["OBJC_DISABLE_INITIALIZE_FORK_SAFETY"] = "YES" @@ -26,6 +22,10 @@ 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 +from views.tab8_financial_statement import render_tab8 +from views.tab9_portfolio import render_tab9 +from views.tab10_valuation import render_tab10 +from views.tab11_estimates import render_tab11 try: import yfinance as yf @@ -64,14 +64,18 @@ if ticker_data: 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", +tab1, tab2, tab3, tab4, tab5, tab6, tab7, tab8, tab9, tab10, tab11 = st.tabs([ + "\U0001f4ca 10-K & MD&A", + "\U0001f4b0 DCF", + "\U0001f3ed Comps", + "\U0001f4f0 News", + "\U0001f30d Markets", "\u20bf Crypto", - "\U0001f6e1 Technical & Risk", + "\U0001f6e1 Technical", + "\U0001f4c4 Financials", + "\U0001f4bc Portfolio", + "\U0001f4b9 Valuation", + "\U0001f4c8 Estimates", ]) # ----- Tab 1: 10-K & MD&A Insights ----- @@ -93,26 +97,32 @@ with tab1: render_tab1_ai_analysis(ticker, quant_ticker, market) render_tab1_filings(ticker, market) -# ----- Tab 2: DCF Valuation ----- with tab2: render_tab2(ticker) -# ----- Tab 3: Industry Comps ----- with tab3: render_tab3(ticker) -# ----- 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) + +with tab8: + render_tab8(ticker) + +with tab9: + render_tab9() + +with tab10: + render_tab10(ticker) + +with tab11: + render_tab11(ticker) diff --git a/atlas-terminal/.github/workflows/update-readme.yml b/atlas-terminal/.github/workflows/update-readme.yml new file mode 100644 index 0000000..ee92ad8 --- /dev/null +++ b/atlas-terminal/.github/workflows/update-readme.yml @@ -0,0 +1,41 @@ +name: Update README + +on: + push: + branches: [main] + paths-ignore: + - 'README.md' + +jobs: + generate-readme: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Generate README + run: python scripts/generate_readme.py + + - name: Check for changes + id: diff + run: | + git diff --quiet README.md && echo "changed=false" >> "$GITHUB_OUTPUT" || echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Commit and push + if: steps.diff.outputs.changed == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add README.md + git commit -m "docs: auto-update README [skip ci]" + git push diff --git a/atlas-terminal/README.md b/atlas-terminal/README.md new file mode 100644 index 0000000..1fdf85f --- /dev/null +++ b/atlas-terminal/README.md @@ -0,0 +1,73 @@ +# ATLAS Terminal + +> Personal Bloomberg-style financial terminal -- real-time market data, +> AI-powered analysis, DCF valuation, and portfolio management. + +## Features + +- **SEC EDGAR** -- 10-K filing download and section extraction +- **AI Analysis** -- Gemini-powered financial statement analysis +- **DCF Valuation** -- Single-stage, two-stage, and Excel-style DCF models with Damodaran WACC +- **Market Data** -- Live stock quotes, indices, and sector data +- **News** -- Financial news aggregation via RSS feeds +- **Crypto** -- Top 20 cryptocurrency prices (Bithumb KRW + Binance USD) +- **FX** -- Foreign exchange rates and 1-year history via yfinance +- **Portfolio** -- Position tracking with P&L and multi-currency support +- **Financial Health** -- DuPont analysis, Altman Z-Score, Piotroski F-Score, radar charts + +## Tech Stack + +**Backend:** Python 3.12+, FastAPI, Pydantic v2, yfinance, yahooquery, Google Generative AI, Supabase + +**Frontend:** Next.js 14, TypeScript, Tailwind CSS + +## Quick Start + +```bash +# Backend +cd atlas-terminal +pip install -r requirements.txt +cp .env.example .env # configure API keys +uvicorn server.main:app --reload --port 8000 + +# Frontend +cd apps/web +npm install +npm run dev +``` + +The API will be available at `http://localhost:8000` and the web UI at `http://localhost:3000`. + +## Project Structure + +``` +atlas-terminal/ + server/ + main.py # FastAPI entry point + models/ # Pydantic schemas, Supabase client + routers/ # API route handlers + services/ # Business logic, data fetchers + utils/ # safe_float, ticker utilities + apps/web/ # Next.js frontend + supabase/migrations/ # Database schema + tests/ # pytest test suite + scripts/ # Automation scripts +``` + +## API Endpoints + +| Prefix | Description | +|------------------|------------------------------------| +| `/api/edgar` | SEC EDGAR 10-K filings | +| `/api/analysis` | AI-powered financial analysis | +| `/api/valuation` | DCF valuation and smart defaults | +| `/api/market` | Stock quotes and market overview | +| `/api/news` | Financial news feeds | +| `/api/crypto` | Cryptocurrency prices | +| `/api/fx` | Foreign exchange rates and history | +| `/api/portfolio` | Portfolio position management | +| `/health` | Liveness probe | + +## License + +Private project. diff --git a/atlas-terminal/apps/web/.eslintrc.json b/atlas-terminal/apps/web/.eslintrc.json new file mode 100644 index 0000000..3722418 --- /dev/null +++ b/atlas-terminal/apps/web/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/atlas-terminal/apps/web/.gitignore b/atlas-terminal/apps/web/.gitignore new file mode 100644 index 0000000..fd3dbb5 --- /dev/null +++ b/atlas-terminal/apps/web/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/atlas-terminal/apps/web/README.md b/atlas-terminal/apps/web/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/atlas-terminal/apps/web/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/atlas-terminal/apps/web/next.config.mjs b/atlas-terminal/apps/web/next.config.mjs new file mode 100644 index 0000000..d0744c8 --- /dev/null +++ b/atlas-terminal/apps/web/next.config.mjs @@ -0,0 +1,13 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + async rewrites() { + return [ + { + source: "/api/:path*", + destination: "http://localhost:8000/api/:path*", + }, + ]; + }, +}; + +export default nextConfig; diff --git a/atlas-terminal/apps/web/package.json b/atlas-terminal/apps/web/package.json new file mode 100644 index 0000000..a65ab7d --- /dev/null +++ b/atlas-terminal/apps/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "lightweight-charts": "^5.1.0", + "next": "14.2.35", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.2.35", + "postcss": "^8", + "tailwindcss": "^3.4.1", + "typescript": "^5" + } +} diff --git a/atlas-terminal/apps/web/postcss.config.mjs b/atlas-terminal/apps/web/postcss.config.mjs new file mode 100644 index 0000000..1a69fd2 --- /dev/null +++ b/atlas-terminal/apps/web/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/atlas-terminal/apps/web/src/app/components/chat-panel.tsx b/atlas-terminal/apps/web/src/app/components/chat-panel.tsx new file mode 100644 index 0000000..3212b70 --- /dev/null +++ b/atlas-terminal/apps/web/src/app/components/chat-panel.tsx @@ -0,0 +1,124 @@ +"use client"; +import { useState, useEffect, useRef } from "react"; + +export function ChatPanel() { + const [messages, setMessages] = useState>([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [ticker, setTicker] = useState("AAPL"); + const scrollRef = useRef(null); + + useEffect(() => { + const saved = localStorage.getItem("atlas_active_ticker"); + if (saved) setTicker(saved); + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail) setTicker(detail); + }; + window.addEventListener("atlas-ticker-change", handler); + return () => window.removeEventListener("atlas-ticker-change", handler); + }, []); + + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); + }, [messages]); + + async function handleSend() { + if (!input.trim() || loading) return; + const userMsg = input.trim(); + setInput(""); + setMessages((prev) => [...prev, { role: "user", content: userMsg }]); + setLoading(true); + + try { + const apiKey = localStorage.getItem("atlas_gemini_key") || ""; + const res = await fetch("/api/analysis/strategy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ticker, question: userMsg, api_key: apiKey }), + }); + if (res.ok) { + const data = await res.json(); + const text = typeof data === "string" ? data : data.analysis || data.result || JSON.stringify(data); + setMessages((prev) => [...prev, { role: "assistant", content: text }]); + } else { + setMessages((prev) => [ + ...prev, + { role: "assistant", content: "Settingsμ—μ„œ Gemini API Keyλ₯Ό μ„€μ •ν•΄μ£Όμ„Έμš”." }, + ]); + } + } catch { + setMessages((prev) => [...prev, { role: "assistant", content: "μ—°κ²° 였λ₯˜. λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”." }]); + } + setLoading(false); + } + + const suggestions = [ + "Is this company undervalued?", + "Analyze the financial health", + "What are the key risks?", + ]; + + return ( + + ); +} diff --git a/atlas-terminal/apps/web/src/app/components/sidebar.tsx b/atlas-terminal/apps/web/src/app/components/sidebar.tsx new file mode 100644 index 0000000..0d916c9 --- /dev/null +++ b/atlas-terminal/apps/web/src/app/components/sidebar.tsx @@ -0,0 +1,99 @@ +"use client"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useState, useEffect } from "react"; + +const NAV_ITEMS = [ + { href: "/", label: "Overview", icon: "πŸ“Š" }, + { href: "/research", label: "Research", icon: "πŸ”¬" }, + { href: "/valuation", label: "Valuation", icon: "πŸ’°" }, + { href: "/technical", label: "Technical", icon: "πŸ“ˆ" }, + { href: "/markets", label: "Markets", icon: "🌍" }, + { href: "/earnings", label: "Earnings", icon: "πŸ“…" }, + { href: "/news", label: "News", icon: "πŸ“°" }, + { href: "/portfolio", label: "Portfolio", icon: "πŸ’Ό" }, + { href: "/filings", label: "Filings", icon: "πŸ“‘" }, +]; + +export function Sidebar() { + const pathname = usePathname(); + const [input, setInput] = useState(""); + const [ticker, setTickerLocal] = useState("AAPL"); + + useEffect(() => { + const saved = localStorage.getItem("atlas_active_ticker"); + if (saved) setTickerLocal(saved); + + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail) setTickerLocal(detail); + }; + window.addEventListener("atlas-ticker-change", handler); + return () => window.removeEventListener("atlas-ticker-change", handler); + }, []); + + function handleSearch() { + const val = input.trim().toUpperCase(); + if (val) { + setTickerLocal(val); + localStorage.setItem("atlas_active_ticker", val); + window.dispatchEvent(new CustomEvent("atlas-ticker-change", { detail: val })); + setInput(""); + } + } + + return ( + + ); +} diff --git a/atlas-terminal/apps/web/src/app/components/ticker-bar.tsx b/atlas-terminal/apps/web/src/app/components/ticker-bar.tsx new file mode 100644 index 0000000..8d01f91 --- /dev/null +++ b/atlas-terminal/apps/web/src/app/components/ticker-bar.tsx @@ -0,0 +1,63 @@ +"use client"; +import { useEffect, useState } from "react"; + +interface IndexData { + label: string; + symbol: string; + price: string; + change: string; + positive: boolean; +} + +const INDICES = [ + { label: "S&P 500", symbol: "^GSPC" }, + { label: "NASDAQ", symbol: "^IXIC" }, + { label: "KOSPI", symbol: "^KS11" }, + { label: "BTC", symbol: "BTC-USD" }, +]; + +export function TickerBar() { + const [data, setData] = useState( + INDICES.map((i) => ({ ...i, price: "β€”", change: "β€”", positive: true })) + ); + + useEffect(() => { + async function load() { + try { + const res = await fetch(`/api/market/indices`); + if (res.ok) { + const json = await res.json(); + if (Array.isArray(json)) { + setData(json); + } + } + } catch { + // keep defaults + } + } + load(); + const iv = setInterval(load, 60_000); + return () => clearInterval(iv); + }, []); + + return ( +
+
+ ATLAS TERMINAL +
+
+ {data.map((idx) => ( +
+ {idx.label} + {idx.price} + {idx.change !== "β€”" && ( + + {idx.change} + + )} +
+ ))} +
+
+ ); +} diff --git a/atlas-terminal/apps/web/src/app/earnings/page.tsx b/atlas-terminal/apps/web/src/app/earnings/page.tsx new file mode 100644 index 0000000..d784bff --- /dev/null +++ b/atlas-terminal/apps/web/src/app/earnings/page.tsx @@ -0,0 +1,170 @@ +"use client"; +import { useEffect, useState } from "react"; +import { useTicker } from "../lib/use-ticker"; + +interface EarningsRecord { + date: string; + eps_actual: number | null; + eps_estimate: number | null; + surprise: number | null; +} + +interface CalendarData { + next_earnings: string | null; + revenue_estimate: number | null; + eps_estimate: number | null; +} + +interface QuarterlyData { + period: string; + revenue: number | null; + earnings: number | null; +} + +export default function EarningsPage() { + const { ticker } = useTicker(); + const [history, setHistory] = useState([]); + const [calendar, setCalendar] = useState(null); + const [quarterly, setQuarterly] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + Promise.all([ + fetch(`/api/earnings/${ticker}/history`).then((r) => r.ok ? r.json() : null), + fetch(`/api/earnings/${ticker}/calendar`).then((r) => r.ok ? r.json() : null), + fetch(`/api/earnings/${ticker}/quarterly`).then((r) => r.ok ? r.json() : null), + ]).then(([h, c, q]) => { + setHistory(h?.history || []); + setCalendar(c); + setQuarterly(q?.quarterly || []); + setLoading(false); + }).catch(() => setLoading(false)); + }, [ticker]); + + if (loading) return
Loading...
; + + return ( +
+

+ {ticker} Earnings +

+ + {/* Next Earnings + Estimates */} +
+
+
Next Earnings Date
+
+ {calendar?.next_earnings || "TBD"} +
+
+
+
EPS Estimate
+
+ {calendar?.eps_estimate != null ? `$${calendar.eps_estimate.toFixed(2)}` : "β€”"} +
+
+
+
Revenue Estimate
+
+ {calendar?.revenue_estimate != null ? `$${(calendar.revenue_estimate / 1e9).toFixed(2)}B` : "β€”"} +
+
+
+ + {/* EPS History β€” Beat/Miss Chart */} +
+

EPS History β€” Beat/Miss

+ {history.length > 0 ? ( +
+ {/* Visual bars */} +
+ {history.map((h, i) => { + const beat = h.eps_actual != null && h.eps_estimate != null && h.eps_actual >= h.eps_estimate; + const barHeight = h.surprise != null ? Math.min(Math.abs(h.surprise) * 2, 100) : 20; + return ( +
+
+
{h.date?.slice(0, 7)}
+
+ ); + })} +
+ + {/* Table */} + + + + + + + + + + + + {history.map((h, i) => { + const beat = h.eps_actual != null && h.eps_estimate != null && h.eps_actual >= h.eps_estimate; + return ( + + + + + + + + ); + })} + +
DateEPS EstimateEPS ActualSurprise %Result
{h.date} + {h.eps_estimate != null ? `$${h.eps_estimate.toFixed(2)}` : "β€”"} + + {h.eps_actual != null ? `$${h.eps_actual.toFixed(2)}` : "β€”"} + = 0 ? "text-accent-green" : "text-accent-red"}`}> + {h.surprise != null ? `${h.surprise >= 0 ? "+" : ""}${h.surprise.toFixed(2)}%` : "β€”"} + + + {beat ? "BEAT" : "MISS"} + +
+
+ ) : ( +
No earnings history available
+ )} +
+ + {/* Quarterly Revenue & Earnings */} + {quarterly.length > 0 && ( +
+

Quarterly Revenue & Earnings

+
+ {quarterly.map((q, i) => ( +
+
{q.period}
+
+
+ Revenue + + {q.revenue != null ? `$${(q.revenue / 1e9).toFixed(2)}B` : "β€”"} + +
+
+ Earnings + = 0 ? "text-accent-green" : "text-accent-red"}`}> + {q.earnings != null ? `$${(q.earnings / 1e9).toFixed(2)}B` : "β€”"} + +
+
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/atlas-terminal/apps/web/src/app/favicon.ico b/atlas-terminal/apps/web/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/atlas-terminal/apps/web/src/app/favicon.ico differ diff --git a/atlas-terminal/apps/web/src/app/filings/page.tsx b/atlas-terminal/apps/web/src/app/filings/page.tsx new file mode 100644 index 0000000..2297268 --- /dev/null +++ b/atlas-terminal/apps/web/src/app/filings/page.tsx @@ -0,0 +1,238 @@ +"use client"; +import { useState } from "react"; +import { useTicker } from "../lib/use-ticker"; + +const SECTIONS = [ + { key: "item1a", label: "Item 1A: Risk Factors", short: "Risk Factors" }, + { key: "item7", label: "Item 7: MD&A", short: "MD&A" }, + { key: "item8", label: "Item 8: Financial Statements", short: "Financials" }, + { key: "item3", label: "Item 3: Legal Proceedings", short: "Legal" }, + { key: "item9a", label: "Item 9A: Controls & Procedures", short: "Controls" }, +]; + +export default function FilingsPage() { + const { ticker } = useTicker(); + const [activeSection, setActiveSection] = useState("item7"); + const [sections, setSections] = useState>({}); + const [loading, setLoading] = useState(false); + const [loaded, setLoaded] = useState(false); + const [email, setEmail] = useState("kimseonpil23@gmail.com"); + const [aiSummary, setAiSummary] = useState(""); + const [aiLoading, setAiLoading] = useState(false); + const [error, setError] = useState(""); + + async function loadFiling() { + setLoading(true); + setError(""); + setSections({}); + setAiSummary(""); + try { + const res = await fetch(`/api/edgar/sections/${ticker}?email=${encodeURIComponent(email)}`); + if (res.ok) { + const data = await res.json(); + setSections({ + item1a: data.item1a || "", + item3: data.item3 || "", + item7: data.item7 || "", + item8: data.item8 || "", + item9a: data.item9a || "", + }); + setLoaded(true); + } else { + const err = await res.json().catch(() => ({})); + setError(err.detail || "Failed to load SEC filing. Try a different ticker or check your connection."); + } + } catch { + setError("Connection error. Make sure the backend server is running."); + } + setLoading(false); + } + + async function runAiSummary() { + const content = sections[activeSection]; + if (!content) return; + const apiKey = localStorage.getItem("atlas_gemini_key") || ""; + if (!apiKey) { + setAiSummary("Please set your Gemini API key in Settings first."); + return; + } + setAiLoading(true); + try { + const sectionLabel = SECTIONS.find((s) => s.key === activeSection)?.label || activeSection; + const res = await fetch("/api/analysis/mda", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ticker, + question: `Summarize and analyze this 10-K ${sectionLabel} section. Highlight key risks, trends, and important disclosures:\n\n${content.slice(0, 8000)}`, + api_key: apiKey, + }), + }); + if (res.ok) { + const data = await res.json(); + setAiSummary(typeof data === "string" ? data : data.analysis || JSON.stringify(data)); + } + } catch { + setAiSummary("Error generating summary."); + } + setAiLoading(false); + } + + const currentContent = sections[activeSection] || ""; + const wordCount = currentContent ? currentContent.split(/\s+/).length : 0; + + return ( +
+

+ {ticker} SEC Filings +

+ + {/* Load Section */} + {!loaded && ( +
+

10-K Annual Report

+

+ Downloads the latest 10-K filing from SEC EDGAR, parses and extracts individual sections for analysis. +

+
+ setEmail(e.target.value)} + placeholder="SEC EDGAR email (required)" + className="bg-bg-primary border border-border rounded-md px-3 py-2 text-text-primary outline-none text-sm w-72 focus:border-accent-green/50" + /> + +
+ {error && ( +
+ {error} +
+ )} + {loading && ( +
+ Downloading from SEC EDGAR... This may take 10-30 seconds for first download. +
+ )} +
+ )} + + {/* Loaded Content */} + {loaded && ( + <> + {/* Section Tabs */} +
+ {SECTIONS.map((s) => { + const hasContent = !!sections[s.key]; + return ( + + ); + })} +
+ + {/* Section Header Bar */} +
+
+

+ {SECTIONS.find((s) => s.key === activeSection)?.label} +

+ {currentContent && ( + {wordCount.toLocaleString()} words + )} +
+
+ {currentContent && ( + + )} + +
+
+ + {/* AI Summary */} + {aiSummary && ( +
+
+ πŸ€– +

AI Analysis

+
+
{aiSummary}
+
+ )} + + {/* Filing Content - Inline Display */} + {currentContent ? ( +
+
+ {currentContent.split("\n").map((line, i) => { + const trimmed = line.trim(); + if (!trimmed) return
; + + // Detect headers (all-caps lines or lines starting with "Item") + const isHeader = /^(Item\s+\d|ITEM\s+\d)/i.test(trimmed) || + (trimmed.length < 80 && trimmed === trimmed.toUpperCase() && /[A-Z]/.test(trimmed)); + const isBullet = /^[β€’\-\*●]\s/.test(trimmed) || /^\d+\.\s/.test(trimmed); + + if (isHeader) { + return ( +

+ {trimmed} +

+ ); + } + if (isBullet) { + return ( +
+ {trimmed} +
+ ); + } + return ( +

+ {trimmed} +

+ ); + })} +
+
+ ) : ( +
+ No content available for this section. +
+ )} + + )} +
+ ); +} diff --git a/atlas-terminal/apps/web/src/app/fonts/GeistMonoVF.woff b/atlas-terminal/apps/web/src/app/fonts/GeistMonoVF.woff new file mode 100644 index 0000000..f2ae185 Binary files /dev/null and b/atlas-terminal/apps/web/src/app/fonts/GeistMonoVF.woff differ diff --git a/atlas-terminal/apps/web/src/app/fonts/GeistVF.woff b/atlas-terminal/apps/web/src/app/fonts/GeistVF.woff new file mode 100644 index 0000000..1b62daa Binary files /dev/null and b/atlas-terminal/apps/web/src/app/fonts/GeistVF.woff differ diff --git a/atlas-terminal/apps/web/src/app/globals.css b/atlas-terminal/apps/web/src/app/globals.css new file mode 100644 index 0000000..03741ed --- /dev/null +++ b/atlas-terminal/apps/web/src/app/globals.css @@ -0,0 +1,40 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + background: #0A0A0F; + color: #F3F4F6; + font-family: "Inter", system-ui, sans-serif; +} + +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: #0A0A0F; +} +::-webkit-scrollbar-thumb { + background: #2A2A3A; + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: #3A3A4A; +} + +input::placeholder { + color: #6B7280; +} diff --git a/atlas-terminal/apps/web/src/app/layout.tsx b/atlas-terminal/apps/web/src/app/layout.tsx new file mode 100644 index 0000000..717a88e --- /dev/null +++ b/atlas-terminal/apps/web/src/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import { Sidebar } from "./components/sidebar"; +import { TickerBar } from "./components/ticker-bar"; +import { ChatPanel } from "./components/chat-panel"; + +export const metadata: Metadata = { + title: "ATLAS Terminal", + description: "Advanced Trading & Liquidity Analysis System", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + +
+ +
+ {children} +
+ +
+ + + ); +} diff --git a/atlas-terminal/apps/web/src/app/lib/api.ts b/atlas-terminal/apps/web/src/app/lib/api.ts new file mode 100644 index 0000000..4703d15 --- /dev/null +++ b/atlas-terminal/apps/web/src/app/lib/api.ts @@ -0,0 +1,17 @@ +const BASE = "/api"; + +export async function apiFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + ...init, + headers: { "Content-Type": "application/json", ...init?.headers }, + }); + if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`); + return res.json(); +} + +export async function apiPost(path: string, body: unknown): Promise { + return apiFetch(path, { + method: "POST", + body: JSON.stringify(body), + }); +} diff --git a/atlas-terminal/apps/web/src/app/lib/use-ticker.ts b/atlas-terminal/apps/web/src/app/lib/use-ticker.ts new file mode 100644 index 0000000..50dfcca --- /dev/null +++ b/atlas-terminal/apps/web/src/app/lib/use-ticker.ts @@ -0,0 +1,34 @@ +"use client"; +import { useState, useEffect, useCallback } from "react"; + +const DEFAULT_TICKER = "AAPL"; +const STORAGE_KEY = "atlas_active_ticker"; +const EVENT_NAME = "atlas-ticker-change"; + +function getInitialTicker(): string { + if (typeof window === "undefined") return DEFAULT_TICKER; + return localStorage.getItem(STORAGE_KEY) || DEFAULT_TICKER; +} + +export function useTicker() { + const [ticker, setTickerState] = useState(getInitialTicker); + + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail) setTickerState(detail); + }; + window.addEventListener(EVENT_NAME, handler); + return () => window.removeEventListener(EVENT_NAME, handler); + }, []); + + const setTicker = useCallback((val: string) => { + const upper = val.trim().toUpperCase(); + if (!upper) return; + setTickerState(upper); + localStorage.setItem(STORAGE_KEY, upper); + window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: upper })); + }, []); + + return { ticker, setTicker }; +} diff --git a/atlas-terminal/apps/web/src/app/markets/page.tsx b/atlas-terminal/apps/web/src/app/markets/page.tsx new file mode 100644 index 0000000..a6ee21b --- /dev/null +++ b/atlas-terminal/apps/web/src/app/markets/page.tsx @@ -0,0 +1,334 @@ +"use client"; +import { useEffect, useState } from "react"; +import { useTicker } from "../lib/use-ticker"; + +type StatementType = "income_statement" | "balance_sheet" | "cash_flow"; + +interface FinancialStatements { + income_statement?: Record[]; + balance_sheet?: Record[]; + cash_flow?: Record[]; +} + +const TABS: { key: StatementType; label: string }[] = [ + { key: "income_statement", label: "Income Statement" }, + { key: "balance_sheet", label: "Balance Sheet" }, + { key: "cash_flow", label: "Cash Flow" }, +]; + +// Define the row structure for each statement type +interface RowDef { + key: string; + label: string; + isHeader?: boolean; + isGrowth?: boolean; + indent?: boolean; + bold?: boolean; +} + +// Keys support both yahooquery (CamelCase) and yfinance (Spaced) formats +const INCOME_ROWS: RowDef[] = [ + { key: "Total Revenue|TotalRevenue|Operating Revenue|OperatingRevenue", label: "Total Revenue", bold: true }, + { key: "_revenue_yoy", label: "YoY Growth (%)", isGrowth: true }, + { key: "Cost Of Revenue|CostOfRevenue|Reconciled Cost Of Revenue", label: "Cost of Revenue", indent: true }, + { key: "Gross Profit|GrossProfit", label: "Gross Profit", bold: true }, + { key: "_grossprofit_yoy", label: "YoY Growth (%)", isGrowth: true }, + { key: "_GrossMargin", label: "Gross Margin (%)", isGrowth: true }, + { key: "Selling General And Administration|SellingGeneralAndAdministration", label: "SG&A Expenses", indent: true }, + { key: "Research And Development|ResearchAndDevelopment", label: "R&D Expenses", indent: true }, + { key: "Operating Expense|OperatingExpense|Total Expenses", label: "Total Operating Expenses", indent: true }, + { key: "Operating Income|OperatingIncome|EBIT", label: "Operating Income (EBIT)", bold: true }, + { key: "_operatingincome_yoy", label: "YoY Growth (%)", isGrowth: true }, + { key: "_OperatingMargin", label: "Operating Margin (%)", isGrowth: true }, + { key: "Interest Expense|InterestExpense", label: "Interest Expense", indent: true }, + { key: "Other Income Expense|OtherIncomeExpense", label: "Other Income/Expense", indent: true }, + { key: "Pretax Income|PretaxIncome", label: "Income Before Tax", bold: true }, + { key: "_pretaxincome_yoy", label: "YoY Growth (%)", isGrowth: true }, + { key: "Tax Provision|TaxProvision", label: "Income Tax Expense", indent: true }, + { key: "_TaxRate", label: "Effective Tax Rate (%)", isGrowth: true }, + { key: "Net Income|NetIncome", label: "Net Income", bold: true }, + { key: "_netincome_yoy", label: "YoY Growth (%)", isGrowth: true }, + { key: "_NetMargin", label: "Net Margin (%)", isGrowth: true }, + { key: "EBITDA|Normalized EBITDA|NormalizedEBITDA", label: "EBITDA", bold: true }, + { key: "Basic EPS|BasicEPS", label: "Basic EPS" }, + { key: "Diluted EPS|DilutedEPS", label: "Diluted EPS" }, + { key: "Basic Average Shares|BasicAverageShares", label: "Shares Outstanding (Basic)" }, +]; + +const BALANCE_ROWS: RowDef[] = [ + { key: "Total Assets|TotalAssets", label: "Total Assets", bold: true }, + { key: "Current Assets|CurrentAssets", label: "Current Assets", bold: true }, + { key: "Cash And Cash Equivalents|CashAndCashEquivalents", label: "Cash & Equivalents", indent: true }, + { key: "Cash Cash Equivalents And Short Term Investments|CashCashEquivalentsAndShortTermInvestments", label: "Cash & Short-term Investments", indent: true }, + { key: "Receivables", label: "Receivables", indent: true }, + { key: "Inventory", label: "Inventory", indent: true }, + { key: "Other Current Assets|OtherCurrentAssets", label: "Other Current Assets", indent: true }, + { key: "Total Non Current Assets|TotalNonCurrentAssets", label: "Non-Current Assets", bold: true }, + { key: "Net PPE|NetPPE", label: "PP&E (Net)", indent: true }, + { key: "Goodwill And Other Intangible Assets|GoodwillAndOtherIntangibleAssets|Goodwill", label: "Goodwill & Intangibles", indent: true }, + { key: "Total Liabilities Net Minority Interest|TotalLiabilitiesNetMinorityInterest", label: "Total Liabilities", bold: true }, + { key: "Current Liabilities|CurrentLiabilities", label: "Current Liabilities", bold: true }, + { key: "Current Debt|CurrentDebt|Current Debt And Capital Lease Obligation", label: "Current Debt", indent: true }, + { key: "Accounts Payable|AccountsPayable", label: "Accounts Payable", indent: true }, + { key: "Total Non Current Liabilities Net Minority Interest|TotalNonCurrentLiabilitiesNetMinorityInterest", label: "Non-Current Liabilities", bold: true }, + { key: "Long Term Debt|LongTermDebt|Long Term Debt And Capital Lease Obligation", label: "Long-term Debt", indent: true }, + { key: "Stockholders Equity|StockholdersEquity|Total Equity Gross Minority Interest", label: "Stockholders' Equity", bold: true }, + { key: "Retained Earnings|RetainedEarnings", label: "Retained Earnings", indent: true }, + { key: "Common Stock|CommonStock|Common Stock Equity", label: "Common Stock Equity", indent: true }, +]; + +const CASHFLOW_ROWS: RowDef[] = [ + { key: "Operating Cash Flow|OperatingCashFlow", label: "Operating Cash Flow", bold: true }, + { key: "Net Income|Net Income From Continuing Operations|NetIncome", label: "Net Income", indent: true }, + { key: "Depreciation And Amortization|DepreciationAndAmortization|Depreciation Amortization Depletion", label: "D&A", indent: true }, + { key: "Change In Working Capital|ChangeInWorkingCapital", label: "Change in Working Capital", indent: true }, + { key: "Stock Based Compensation|StockBasedCompensation", label: "Stock-based Compensation", indent: true }, + { key: "Investing Cash Flow|InvestingCashFlow", label: "Investing Cash Flow", bold: true }, + { key: "Capital Expenditure|CapitalExpenditure|Purchase Of PPE", label: "Capital Expenditure", indent: true }, + { key: "Purchase Of Investment|PurchaseOfInvestment", label: "Purchases of Investments", indent: true }, + { key: "Sale Of Investment|SaleOfInvestment", label: "Sales of Investments", indent: true }, + { key: "Financing Cash Flow|FinancingCashFlow", label: "Financing Cash Flow", bold: true }, + { key: "Common Stock Issuance|CommonStockIssuance", label: "Stock Issuance", indent: true }, + { key: "Repurchase Of Capital Stock|RepurchaseOfCapitalStock", label: "Share Buybacks", indent: true }, + { key: "Common Stock Dividend Paid|CommonStockDividendPaid|Cash Dividends Paid", label: "Dividends Paid", indent: true }, + { key: "Issuance Of Debt|DebtIssuance|Long Term Debt Issuance", label: "Debt Issuance", indent: true }, + { key: "Repayment Of Debt|DebtRepayment|Long Term Debt Payments", label: "Debt Repayment", indent: true }, + { key: "Free Cash Flow|FreeCashFlow", label: "Free Cash Flow", bold: true }, + { key: "End Cash Position|EndCashPosition|Changes In Cash", label: "End Cash Position", bold: true }, +]; + +const ROW_MAP: Record = { + income_statement: INCOME_ROWS, + balance_sheet: BALANCE_ROWS, + cash_flow: CASHFLOW_ROWS, +}; + +export default function MarketsPage() { + const { ticker } = useTicker(); + const [data, setData] = useState(null); + const [tab, setTab] = useState("income_statement"); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + fetch(`/api/financials/${ticker}/statements`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + setData(d); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, [ticker]); + + if (loading) + return ( +
+
Loading...
+
+ ); + + const rows = data?.[tab] || []; + const rowDefs = ROW_MAP[tab]; + + // Extract period dates (columns) - skip first period if all nulls + const periods = rows + .filter((r) => { + const vals = Object.entries(r).filter(([k]) => !["period", "asOfDate", "periodType", "currencyCode"].includes(k)); + return vals.some(([, v]) => v != null); + }) + .map((r) => ({ + date: String(r.asOfDate || "").slice(0, 10), + periodType: String(r.periodType || ""), + data: r, + })) + .reverse(); // most recent first + + // Compute derived values + function getValue(periodData: Record, key: string): number | null { + if (key.startsWith("_")) return null; // computed below + // Support pipe-separated key alternatives + const keys = key.split("|"); + for (const k of keys) { + const v = periodData[k.trim()]; + if (v != null && typeof v === "number") return v; + } + return null; + } + + function getComputedValue(periodData: Record, key: string, prevPeriodData?: Record): number | null { + const rev = getValue(periodData, "Total Revenue|TotalRevenue|Operating Revenue|OperatingRevenue"); + if (key === "_GrossMargin") { + const gp = getValue(periodData, "Gross Profit|GrossProfit"); + return rev && gp ? (gp / rev) * 100 : null; + } + if (key === "_OperatingMargin") { + const oi = getValue(periodData, "Operating Income|OperatingIncome|EBIT"); + return rev && oi ? (oi / rev) * 100 : null; + } + if (key === "_NetMargin") { + const ni = getValue(periodData, "Net Income|NetIncome"); + return rev && ni ? (ni / rev) * 100 : null; + } + if (key === "_TaxRate") { + const tax = getValue(periodData, "Tax Provision|TaxProvision"); + const pretax = getValue(periodData, "Pretax Income|PretaxIncome"); + return pretax && tax ? (tax / pretax) * 100 : null; + } + // YoY growth β€” find the matching row definition to get the key alternatives + if (key.endsWith("_yoy") && prevPeriodData) { + const yoyMap: Record = { + "_revenue_yoy": "Total Revenue|TotalRevenue|Operating Revenue|OperatingRevenue", + "_grossprofit_yoy": "Gross Profit|GrossProfit", + "_operatingincome_yoy": "Operating Income|OperatingIncome|EBIT", + "_pretaxincome_yoy": "Pretax Income|PretaxIncome", + "_netincome_yoy": "Net Income|NetIncome", + }; + const multiKey = yoyMap[key]; + if (multiKey) { + const curr = getValue(periodData, multiKey); + const prev = getValue(prevPeriodData, multiKey); + if (curr != null && prev != null && prev !== 0) { + return ((curr - prev) / Math.abs(prev)) * 100; + } + } + } + return null; + } + + function getCellValue(rowDef: RowDef, periodIdx: number): number | null { + if (periods.length === 0) return null; + const pd = periods[periodIdx]?.data; + if (!pd) return null; + + if (rowDef.key.startsWith("_")) { + const prevPd = periodIdx < periods.length - 1 ? periods[periodIdx + 1]?.data : undefined; + return getComputedValue(pd, rowDef.key, prevPd); + } + return getValue(pd, rowDef.key); + } + + function formatCell(val: number | null, rowDef: RowDef): string { + if (val == null) return "β€”"; + if (rowDef.isGrowth) return `${val >= 0 ? "" : ""}${val.toFixed(2)}%`; + if (rowDef.key === "BasicEPS" || rowDef.key === "DilutedEPS") return val.toFixed(2); + if (Math.abs(val) >= 1e9) return `${(val / 1e6).toLocaleString(undefined, { maximumFractionDigits: 0 })}`; + if (Math.abs(val) >= 1e6) return `${(val / 1e6).toLocaleString(undefined, { maximumFractionDigits: 0 })}`; + return val.toLocaleString(undefined, { maximumFractionDigits: 2 }); + } + + // Check if row has any data + function rowHasData(rowDef: RowDef): boolean { + return periods.some((_, i) => getCellValue(rowDef, i) != null); + } + + const filteredRows = rowDefs.filter(rowHasData); + + return ( +
+

+ {ticker} Financial Statements +

+ + {/* Unit Note */} +
Unit: Millions USD (except per-share data)
+ + {/* Tabs */} +
+ {TABS.map((t) => ( + + ))} +
+ + {/* Table */} + {periods.length > 0 ? ( +
+ + {/* Column Headers - Period Dates */} + + + + {periods.map((p, i) => ( + + ))} + + + + {filteredRows.map((rowDef, ri) => { + const isGrowth = rowDef.isGrowth; + return ( + + {/* Row Label */} + + {/* Period Values */} + {periods.map((_, pi) => { + const val = getCellValue(rowDef, pi); + const formatted = formatCell(val, rowDef); + let colorClass = "text-text-primary"; + + if (isGrowth && val != null) { + if (val > 0) colorClass = "text-accent-green"; + else if (val < 0) colorClass = "text-accent-red"; + else colorClass = "text-text-muted"; + } else if (val != null && val < 0 && !isGrowth) { + colorClass = "text-accent-red"; + } + + return ( + + ); + })} + + ); + })} + +
+ {tab === "income_statement" ? "Income Statement" : tab === "balance_sheet" ? "Balance Sheet" : "Cash Flow Statement"} + +
{p.date.slice(0, 4)}
+
{p.date}
+
+ {isGrowth ? `↳ ${rowDef.label}` : rowDef.label} + + {isGrowth && val != null ? ( + 0 ? "bg-accent-green/15 text-accent-green" : val < 0 ? "bg-accent-red/15 text-accent-red" : "bg-bg-primary text-text-muted" + }`} + > + {formatted} + + ) : ( + formatted + )} +
+
+ ) : ( +
+ No financial data available for {ticker} +
+ )} +
+ ); +} diff --git a/atlas-terminal/apps/web/src/app/news/page.tsx b/atlas-terminal/apps/web/src/app/news/page.tsx new file mode 100644 index 0000000..e8f7df4 --- /dev/null +++ b/atlas-terminal/apps/web/src/app/news/page.tsx @@ -0,0 +1,142 @@ +"use client"; +import { useEffect, useState } from "react"; +import { useTicker } from "../lib/use-ticker"; + +interface NewsItem { + title: string; + source: string; + url: string; + published_at: string; + summary: string; +} + +export default function NewsPage() { + const { ticker } = useTicker(); + const [news, setNews] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedIdx, setSelectedIdx] = useState(null); + + useEffect(() => { + setLoading(true); + setSelectedIdx(null); + fetch(`/api/news/${ticker}`) + .then((r) => (r.ok ? r.json() : [])) + .then((data) => { + setNews(Array.isArray(data) ? data : []); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, [ticker]); + + if (loading) + return ( +
+
Loading...
+
+ ); + + const selectedItem = selectedIdx !== null ? news[selectedIdx] : null; + + return ( +
+

+ {ticker} News Feed +

+ +
+ {news.length} articles from Finviz & Google News +
+ +
+ {/* Article List */} +
+
+ {news.length > 0 ? ( + news.map((item, i) => ( +
setSelectedIdx(i)} + className={`cursor-pointer rounded-lg p-3 transition-all border ${ + selectedIdx === i + ? "bg-accent-green/10 border-accent-green/50" + : "bg-bg-card border-border hover:border-accent-green/30" + }`} + > +

+ {item.title} +

+
+ {item.source && ( + + {item.source} + + )} + + {item.published_at} + +
+
+ )) + ) : ( +
No news articles found
+ )} +
+
+ + {/* Article Content - Right Panel */} + {selectedItem && ( +
+ {/* Header */} +
+

+ {selectedItem.title} +

+
+ {selectedItem.source && ( + + {selectedItem.source} + + )} + + {selectedItem.published_at} + + + Open Original β†— + + +
+
+ {/* Article Embed */} +
+