feat: add Atlas Terminal — Next.js 14 + FastAPI full-stack migration

Complete migration from Streamlit to Next.js 14 App Router + FastAPI backend.

Frontend (Next.js 14):
- 10 pages: Overview, Research, Valuation, Technical, Markets, Earnings, News, Portfolio, Filings, Settings
- Terminal Noir dark theme with custom Tailwind config
- TradingView Lightweight Charts for candlestick/volume
- Valuation: DCF, Sensitivity Matrix, Monte Carlo, Tornado, Reverse DCF
- Financial Statements table with YoY growth badges and margin rows
- SEC EDGAR inline filing viewer with section tabs
- News split-view with iframe article embedding
- Technical Analysis with RSI, MACD, Bollinger, Fibonacci, Moving Averages
- Earnings beat/miss visualization
- AI Copilot chat panel with Gemini integration

Backend (FastAPI):
- 13 routers: market_data, financials, valuation, technical, earnings, insider, edgar, news, portfolio, analysis, chat, estimates, fx
- Services: DCF engine, Monte Carlo simulation, sensitivity analysis, risk metrics, SEC parser, technical indicators
- yfinance + yahooquery data sources with fallback pattern
- SQLite caching layer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shawnkim1997
2026-03-21 02:10:10 +00:00
parent 56a9561f71
commit b2acda81ee
111 changed files with 13883 additions and 270 deletions
+13
View File
@@ -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.
+27
View File
@@ -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
+167
View File
@@ -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가 참조한다.
---
-223
View File
@@ -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 성장, Y610 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=OCFCapEx, 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 (OCFCapEx 자동 또는 수동), Shares/Debt/Cash (자동 다단계 폴백, 실패 시만 수동).
- **슬라이더:** WACC, Terminal Growth (기본 2.5%), Projected FCF Growth (Y15). 기본값: `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로 1218개월 거시 트렌드, 성장 동력, 리스크 요약.
---
## 7. 핵심 알고리즘
### 7.1 10년 2단계 DCF (`dcf_10y_2stage`)
- **Stage 1 (Y15):** FCF가 매년 `fcf_growth`로 성장.
- **Stage 2 (Y610):** 성장률이 `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 로드 2060초, Gemini 스트리밍 510초; 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`로 저장되었으며, 에이전트·신규 개발자가 전체 구조와 규칙을 빠르게 파악하는 데 활용할 수 있습니다.
-29
View File
@@ -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 16; 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.
+87
View File
@@ -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
+27 -17
View File
@@ -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)
+41
View File
@@ -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
+73
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
+36
View File
@@ -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
+36
View File
@@ -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.
+13
View File
@@ -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;
+27
View File
@@ -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"
}
}
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
@@ -0,0 +1,124 @@
"use client";
import { useState, useEffect, useRef } from "react";
export function ChatPanel() {
const [messages, setMessages] = useState<Array<{ role: string; content: string }>>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [ticker, setTicker] = useState("AAPL");
const scrollRef = useRef<HTMLDivElement>(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 (
<aside className="w-[380px] bg-bg-secondary border-l border-border fixed top-[52px] bottom-0 right-0 flex flex-col z-40">
<div className="p-4 border-b border-border font-bold text-lg text-text-primary">
🤖 AI Copilot
</div>
<div ref={scrollRef} className="flex-1 p-4 overflow-y-auto flex flex-col gap-3">
{messages.length === 0 ? (
<div className="text-text-secondary text-sm">
<p>
Ask me anything about{" "}
<span className="text-accent-green font-semibold">{ticker}</span>.
</p>
<p className="mt-3 font-semibold text-text-primary">Try:</p>
<ul className="flex flex-col gap-1.5 mt-2">
{suggestions.map((q) => (
<li
key={q}
onClick={() => setInput(q)}
className="px-3 py-2.5 bg-bg-card rounded-lg cursor-pointer text-sm text-text-primary hover:bg-bg-hover transition-colors"
>
{q}
</li>
))}
</ul>
</div>
) : (
messages.map((m, i) => (
<div
key={i}
className={`px-3.5 py-2.5 rounded-lg text-sm leading-relaxed max-w-[90%] whitespace-pre-wrap ${
m.role === "user"
? "bg-bg-card text-text-primary self-end"
: "bg-accent-green/10 text-text-primary self-start"
}`}
>
{m.content}
</div>
))
)}
{loading && <div className="text-accent-green text-sm animate-pulse">Thinking...</div>}
</div>
<div className="p-3 border-t border-border">
<div className="flex gap-2 bg-bg-card rounded-lg border border-border px-3.5 py-2.5">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Ask anything..."
className="flex-1 bg-transparent border-none text-text-primary outline-none"
/>
<button
onClick={handleSend}
className="bg-accent-green text-bg-primary border-none rounded-md px-4 py-1.5 font-semibold cursor-pointer text-sm hover:opacity-90 transition-opacity"
>
Send
</button>
</div>
</div>
</aside>
);
}
@@ -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 (
<aside className="w-[260px] bg-bg-primary border-r border-border p-4 flex flex-col gap-2 fixed top-[52px] bottom-0 left-0 overflow-y-auto z-40">
{/* Ticker Search */}
<div>
<div className="flex items-center gap-2 bg-bg-card border border-border rounded-lg px-3.5 py-2.5">
<span className="text-text-muted">🔍</span>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="Search ticker..."
className="bg-transparent border-none text-text-primary outline-none w-full"
/>
</div>
<div className="mt-2 px-3.5 py-1.5 bg-bg-card rounded-md flex items-center justify-between">
<span className="text-text-muted text-sm">Active:</span>
<span className="text-accent-green font-mono font-bold">{ticker}</span>
</div>
</div>
{/* Navigation */}
<nav className="flex flex-col gap-1 mt-3">
{NAV_ITEMS.map((item) => {
const active = pathname === item.href;
return (
<Link key={item.href} href={item.href} className="no-underline">
<div
className={`flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg text-base transition-all duration-150 border-l-2 ${
active
? "bg-accent-green/10 text-accent-green font-semibold border-accent-green"
: "text-text-secondary font-normal border-transparent hover:bg-bg-card"
}`}
>
<span>{item.icon}</span> {item.label}
</div>
</Link>
);
})}
<div className="border-t border-border my-2" />
<Link href="/settings" className="no-underline">
<div
className={`flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg text-base transition-all duration-150 border-l-2 ${
pathname === "/settings"
? "bg-accent-green/10 text-accent-green font-semibold border-accent-green"
: "text-text-secondary font-normal border-transparent hover:bg-bg-card"
}`}
>
<span></span> Settings
</div>
</Link>
</nav>
</aside>
);
}
@@ -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<IndexData[]>(
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 (
<header className="fixed top-0 left-0 right-0 z-50 h-[52px] bg-bg-primary border-b border-border flex items-center px-5 gap-4">
<div className="font-mono font-bold text-accent-green text-lg mr-5">
ATLAS<span className="text-text-secondary font-normal"> TERMINAL</span>
</div>
<div className="flex gap-5 overflow-hidden">
{data.map((idx) => (
<div key={idx.label} className="flex items-center gap-2 text-sm font-mono">
<span className="text-text-muted">{idx.label}</span>
<span className="text-text-primary font-semibold">{idx.price}</span>
{idx.change !== "—" && (
<span className={idx.positive ? "text-accent-green" : "text-accent-red"}>
{idx.change}
</span>
)}
</div>
))}
</div>
</header>
);
}
@@ -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<EarningsRecord[]>([]);
const [calendar, setCalendar] = useState<CalendarData | null>(null);
const [quarterly, setQuarterly] = useState<QuarterlyData[]>([]);
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 <div className="flex items-center justify-center h-64"><div className="text-accent-green animate-pulse font-mono">Loading...</div></div>;
return (
<div>
<h1 className="text-2xl font-bold mb-6">
<span className="text-accent-green">{ticker}</span> Earnings
</h1>
{/* Next Earnings + Estimates */}
<div className="grid grid-cols-3 gap-3 mb-6">
<div className="bg-bg-card border border-accent-green rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">Next Earnings Date</div>
<div className="text-accent-green font-mono font-bold text-lg">
{calendar?.next_earnings || "TBD"}
</div>
</div>
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">EPS Estimate</div>
<div className="text-text-primary font-mono font-bold text-lg">
{calendar?.eps_estimate != null ? `$${calendar.eps_estimate.toFixed(2)}` : "—"}
</div>
</div>
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">Revenue Estimate</div>
<div className="text-text-primary font-mono font-bold text-lg">
{calendar?.revenue_estimate != null ? `$${(calendar.revenue_estimate / 1e9).toFixed(2)}B` : "—"}
</div>
</div>
</div>
{/* EPS History — Beat/Miss Chart */}
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
<h3 className="text-text-secondary text-sm font-semibold mb-4">EPS History Beat/Miss</h3>
{history.length > 0 ? (
<div className="space-y-0">
{/* Visual bars */}
<div className="flex items-end gap-2 h-32 mb-4">
{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 (
<div key={i} className="flex-1 flex flex-col items-center justify-end h-full">
<div
className={`w-full rounded-t-sm ${beat ? "bg-accent-green" : "bg-accent-red"}`}
style={{ height: `${Math.max(barHeight, 8)}%` }}
/>
<div className="text-text-muted text-[10px] mt-1 font-mono">{h.date?.slice(0, 7)}</div>
</div>
);
})}
</div>
{/* Table */}
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left text-text-muted py-2 font-normal">Date</th>
<th className="text-right text-text-muted py-2 font-normal">EPS Estimate</th>
<th className="text-right text-text-muted py-2 font-normal">EPS Actual</th>
<th className="text-right text-text-muted py-2 font-normal">Surprise %</th>
<th className="text-right text-text-muted py-2 font-normal">Result</th>
</tr>
</thead>
<tbody>
{history.map((h, i) => {
const beat = h.eps_actual != null && h.eps_estimate != null && h.eps_actual >= h.eps_estimate;
return (
<tr key={i} className="border-b border-border/50">
<td className="py-2 text-text-primary font-mono">{h.date}</td>
<td className="py-2 text-text-secondary font-mono text-right">
{h.eps_estimate != null ? `$${h.eps_estimate.toFixed(2)}` : "—"}
</td>
<td className="py-2 text-text-primary font-mono text-right font-semibold">
{h.eps_actual != null ? `$${h.eps_actual.toFixed(2)}` : "—"}
</td>
<td className={`py-2 font-mono text-right ${h.surprise != null && h.surprise >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{h.surprise != null ? `${h.surprise >= 0 ? "+" : ""}${h.surprise.toFixed(2)}%` : "—"}
</td>
<td className="py-2 text-right">
<span className={`text-xs font-semibold px-2 py-0.5 rounded ${
beat ? "bg-accent-green/20 text-accent-green" : "bg-accent-red/20 text-accent-red"
}`}>
{beat ? "BEAT" : "MISS"}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="text-text-muted text-center py-8">No earnings history available</div>
)}
</div>
{/* Quarterly Revenue & Earnings */}
{quarterly.length > 0 && (
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-4">Quarterly Revenue & Earnings</h3>
<div className="grid grid-cols-4 gap-3">
{quarterly.map((q, i) => (
<div key={i} className="bg-bg-primary rounded-lg p-4">
<div className="text-text-muted text-xs mb-2 font-mono">{q.period}</div>
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-text-muted">Revenue</span>
<span className="text-text-primary font-mono">
{q.revenue != null ? `$${(q.revenue / 1e9).toFixed(2)}B` : "—"}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-text-muted">Earnings</span>
<span className={`font-mono ${q.earnings != null && q.earnings >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{q.earnings != null ? `$${(q.earnings / 1e9).toFixed(2)}B` : "—"}
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -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<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const [email, setEmail] = useState("kimseonpil23@gmail.com");
const [aiSummary, setAiSummary] = useState<string>("");
const [aiLoading, setAiLoading] = useState(false);
const [error, setError] = useState<string>("");
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 (
<div>
<h1 className="text-2xl font-bold mb-4">
<span className="text-accent-green">{ticker}</span> SEC Filings
</h1>
{/* Load Section */}
{!loaded && (
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
<h3 className="text-text-secondary text-sm font-semibold mb-3">10-K Annual Report</h3>
<p className="text-text-muted text-sm mb-4">
Downloads the latest 10-K filing from SEC EDGAR, parses and extracts individual sections for analysis.
</p>
<div className="flex items-center gap-3">
<input
value={email}
onChange={(e) => 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"
/>
<button
onClick={loadFiling}
disabled={loading || !email}
className="bg-accent-green text-bg-primary px-5 py-2 rounded-md text-sm font-semibold hover:opacity-90 disabled:opacity-50 transition-opacity"
>
{loading ? "Downloading & Parsing..." : "Load 10-K Filing"}
</button>
</div>
{error && (
<div className="mt-3 bg-accent-red/10 border border-accent-red/30 rounded-md px-4 py-2.5 text-accent-red text-sm">
{error}
</div>
)}
{loading && (
<div className="mt-3 text-text-muted text-sm animate-pulse">
Downloading from SEC EDGAR... This may take 10-30 seconds for first download.
</div>
)}
</div>
)}
{/* Loaded Content */}
{loaded && (
<>
{/* Section Tabs */}
<div className="flex gap-1 mb-4 bg-bg-card border border-border rounded-lg p-1">
{SECTIONS.map((s) => {
const hasContent = !!sections[s.key];
return (
<button
key={s.key}
onClick={() => { setActiveSection(s.key); setAiSummary(""); }}
className={`flex-1 px-3 py-2 rounded-md text-xs font-mono transition-all ${
activeSection === s.key
? "bg-accent-green text-bg-primary font-semibold"
: hasContent
? "text-text-secondary hover:text-text-primary"
: "text-text-muted/50"
}`}
>
{s.short}
</button>
);
})}
</div>
{/* Section Header Bar */}
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-text-primary text-sm font-semibold">
{SECTIONS.find((s) => s.key === activeSection)?.label}
</h2>
{currentContent && (
<span className="text-text-muted text-xs font-mono">{wordCount.toLocaleString()} words</span>
)}
</div>
<div className="flex items-center gap-2">
{currentContent && (
<button
onClick={runAiSummary}
disabled={aiLoading}
className="bg-accent-blue text-white px-4 py-1.5 rounded-md text-xs font-semibold hover:opacity-90 disabled:opacity-50 transition-opacity"
>
{aiLoading ? "Analyzing..." : "AI Summary"}
</button>
)}
<button
onClick={loadFiling}
disabled={loading}
className="bg-bg-card border border-border text-text-secondary px-3 py-1.5 rounded-md text-xs hover:text-text-primary transition-colors"
>
Reload
</button>
</div>
</div>
{/* AI Summary */}
{aiSummary && (
<div className="bg-bg-card border border-accent-green/30 rounded-lg p-5 mb-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-accent-green text-sm">🤖</span>
<h3 className="text-accent-green text-sm font-semibold">AI Analysis</h3>
</div>
<div className="text-text-primary text-sm leading-relaxed whitespace-pre-wrap">{aiSummary}</div>
</div>
)}
{/* Filing Content - Inline Display */}
{currentContent ? (
<div className="bg-bg-card border border-border rounded-lg overflow-hidden">
<div
className="p-6 overflow-y-auto text-text-primary text-sm leading-[1.8] font-sans"
style={{ maxHeight: "calc(100vh - 340px)" }}
>
{currentContent.split("\n").map((line, i) => {
const trimmed = line.trim();
if (!trimmed) return <div key={i} className="h-3" />;
// 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 (
<h3 key={i} className="text-accent-green font-semibold text-base mt-5 mb-2 border-b border-border/30 pb-1">
{trimmed}
</h3>
);
}
if (isBullet) {
return (
<div key={i} className="pl-4 py-0.5 text-text-secondary">
{trimmed}
</div>
);
}
return (
<p key={i} className="mb-1.5 text-text-primary/90">
{trimmed}
</p>
);
})}
</div>
</div>
) : (
<div className="bg-bg-card border border-border rounded-lg p-8 text-center text-text-muted">
No content available for this section.
</div>
)}
</>
)}
</div>
);
}
Binary file not shown.
@@ -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;
}
@@ -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 (
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<TickerBar />
<div className="flex pt-[52px] min-h-screen">
<Sidebar />
<main className="flex-1 ml-[260px] mr-[380px] p-7 bg-bg-primary min-h-[calc(100vh-52px)] transition-all duration-200">
{children}
</main>
<ChatPanel />
</div>
</body>
</html>
);
}
@@ -0,0 +1,17 @@
const BASE = "/api";
export async function apiFetch<T = unknown>(path: string, init?: RequestInit): Promise<T> {
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<T = unknown>(path: string, body: unknown): Promise<T> {
return apiFetch<T>(path, {
method: "POST",
body: JSON.stringify(body),
});
}
@@ -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 };
}
@@ -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<string, unknown>[];
balance_sheet?: Record<string, unknown>[];
cash_flow?: Record<string, unknown>[];
}
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<StatementType, RowDef[]> = {
income_statement: INCOME_ROWS,
balance_sheet: BALANCE_ROWS,
cash_flow: CASHFLOW_ROWS,
};
export default function MarketsPage() {
const { ticker } = useTicker();
const [data, setData] = useState<FinancialStatements | null>(null);
const [tab, setTab] = useState<StatementType>("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 (
<div className="flex items-center justify-center h-64">
<div className="text-accent-green animate-pulse font-mono">Loading...</div>
</div>
);
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<string, unknown>, 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<string, unknown>, key: string, prevPeriodData?: Record<string, unknown>): 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<string, string> = {
"_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 (
<div>
<h1 className="text-2xl font-bold mb-4">
<span className="text-accent-green">{ticker}</span> Financial Statements
</h1>
{/* Unit Note */}
<div className="text-text-muted text-xs mb-3 font-mono">Unit: Millions USD (except per-share data)</div>
{/* Tabs */}
<div className="flex gap-1 mb-4 bg-bg-card rounded-lg p-1 w-fit">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
tab === t.key ? "bg-accent-green text-bg-primary" : "text-text-secondary hover:text-text-primary"
}`}
>
{t.label}
</button>
))}
</div>
{/* Table */}
{periods.length > 0 ? (
<div className="bg-bg-card border border-border rounded-lg overflow-x-auto">
<table className="w-full text-xs">
{/* Column Headers - Period Dates */}
<thead>
<tr className="border-b border-border bg-bg-primary/50">
<th className="text-left px-4 py-3 text-text-muted font-semibold sticky left-0 bg-bg-card min-w-[220px] z-10">
{tab === "income_statement" ? "Income Statement" : tab === "balance_sheet" ? "Balance Sheet" : "Cash Flow Statement"}
</th>
{periods.map((p, i) => (
<th key={i} className="text-right px-4 py-3 text-text-muted font-semibold whitespace-nowrap min-w-[110px]">
<div className="text-text-secondary">{p.date.slice(0, 4)}</div>
<div className="text-text-muted text-[10px]">{p.date}</div>
</th>
))}
</tr>
</thead>
<tbody>
{filteredRows.map((rowDef, ri) => {
const isGrowth = rowDef.isGrowth;
return (
<tr
key={rowDef.key}
className={`border-b border-border/30 ${
isGrowth ? "bg-bg-primary/30" : rowDef.bold ? "bg-bg-primary/10" : ""
} hover:bg-bg-card/80 transition-colors`}
>
{/* Row Label */}
<td
className={`px-4 py-2 sticky left-0 bg-bg-card z-10 ${
rowDef.bold ? "font-semibold text-text-primary" : isGrowth ? "text-text-muted italic text-[11px]" : "text-text-secondary"
} ${rowDef.indent ? "pl-8" : ""} ${isGrowth ? "pl-8" : ""}`}
>
{isGrowth ? `${rowDef.label}` : rowDef.label}
</td>
{/* 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 (
<td
key={pi}
className={`px-4 py-2 text-right font-mono whitespace-nowrap ${colorClass} ${
rowDef.bold && !isGrowth ? "font-semibold" : ""
} ${isGrowth ? "text-[11px]" : ""}`}
>
{isGrowth && val != null ? (
<span
className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${
val > 0 ? "bg-accent-green/15 text-accent-green" : val < 0 ? "bg-accent-red/15 text-accent-red" : "bg-bg-primary text-text-muted"
}`}
>
{formatted}
</span>
) : (
formatted
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="bg-bg-card border border-border rounded-lg p-8 text-center text-text-muted">
No financial data available for {ticker}
</div>
)}
</div>
);
}
@@ -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<NewsItem[]>([]);
const [loading, setLoading] = useState(true);
const [selectedIdx, setSelectedIdx] = useState<number | null>(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 (
<div className="flex items-center justify-center h-64">
<div className="text-accent-green animate-pulse font-mono">Loading...</div>
</div>
);
const selectedItem = selectedIdx !== null ? news[selectedIdx] : null;
return (
<div>
<h1 className="text-2xl font-bold mb-4">
<span className="text-accent-green">{ticker}</span> News Feed
</h1>
<div className="text-text-muted text-sm mb-4">
{news.length} articles from Finviz & Google News
</div>
<div className="flex gap-4" style={{ height: "calc(100vh - 200px)" }}>
{/* Article List */}
<div
className={`${
selectedItem ? "w-[340px] shrink-0" : "w-full"
} overflow-y-auto transition-all duration-200`}
>
<div className="space-y-2">
{news.length > 0 ? (
news.map((item, i) => (
<div
key={i}
onClick={() => 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"
}`}
>
<h3
className={`text-sm font-semibold leading-snug ${
selectedIdx === i ? "text-accent-green" : "text-text-primary"
}`}
>
{item.title}
</h3>
<div className="flex items-center gap-2 mt-2">
{item.source && (
<span className="text-[10px] px-1.5 py-0.5 bg-accent-blue/10 text-accent-blue rounded font-mono">
{item.source}
</span>
)}
<span className="text-text-muted text-[10px] font-mono">
{item.published_at}
</span>
</div>
</div>
))
) : (
<div className="text-text-muted text-center py-12">No news articles found</div>
)}
</div>
</div>
{/* Article Content - Right Panel */}
{selectedItem && (
<div className="flex-1 flex flex-col bg-bg-card border border-border rounded-lg overflow-hidden min-w-0">
{/* Header */}
<div className="px-5 py-4 border-b border-border bg-bg-primary/50 shrink-0">
<h2 className="text-base font-bold text-text-primary leading-snug mb-2">
{selectedItem.title}
</h2>
<div className="flex items-center gap-3">
{selectedItem.source && (
<span className="text-xs px-2 py-0.5 bg-accent-blue/10 text-accent-blue rounded font-mono">
{selectedItem.source}
</span>
)}
<span className="text-text-muted text-xs font-mono">
{selectedItem.published_at}
</span>
<a
href={selectedItem.url}
target="_blank"
rel="noopener noreferrer"
className="ml-auto text-xs px-3 py-1 bg-accent-green text-bg-primary rounded-md font-semibold hover:opacity-90 transition-opacity"
>
Open Original
</a>
<button
onClick={() => setSelectedIdx(null)}
className="text-text-muted hover:text-text-primary transition-colors text-base"
>
</button>
</div>
</div>
{/* Article Embed */}
<div className="flex-1 relative bg-white">
<iframe
src={selectedItem.url}
className="w-full h-full border-0"
sandbox="allow-scripts allow-same-origin allow-popups"
referrerPolicy="no-referrer"
title={selectedItem.title}
/>
</div>
</div>
)}
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { useEffect, useState } from "react";
import { useTicker } from "./lib/use-ticker";
interface HealthData {
dupont?: { roe?: number; npm?: number; asset_turnover?: number; equity_multiplier?: number };
altman_z?: number;
red_flags?: string[];
}
interface SectorData {
sector?: string;
industry?: string;
market_cap?: number;
pe_ratio?: number;
dividend_yield?: number;
beta?: number;
fifty_two_week_high?: number;
fifty_two_week_low?: number;
current_price?: number;
}
export default function OverviewPage() {
const { ticker } = useTicker();
const [sector, setSector] = useState<SectorData | null>(null);
const [health, setHealth] = useState<HealthData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
Promise.all([
fetch(`/api/market/sector/${ticker}`).then((r) => r.ok ? r.json() : null),
fetch(`/api/market/health/${ticker}`).then((r) => r.ok ? r.json() : null),
]).then(([s, h]) => {
setSector(s);
setHealth(h);
setLoading(false);
}).catch(() => setLoading(false));
}, [ticker]);
if (loading) return <LoadingState />;
const metrics = [
{ label: "Sector", value: sector?.sector || "—" },
{ label: "Industry", value: sector?.industry || "—" },
{ label: "Market Cap", value: sector?.market_cap ? `$${(sector.market_cap / 1e9).toFixed(1)}B` : "—" },
{ label: "P/E Ratio", value: sector?.pe_ratio?.toFixed(1) || "—" },
{ label: "Beta", value: sector?.beta?.toFixed(2) || "—" },
{ label: "Div Yield", value: sector?.dividend_yield ? `${sector.dividend_yield.toFixed(2)}%` : "—" },
{ label: "52W High", value: sector?.fifty_two_week_high ? `$${sector.fifty_two_week_high.toFixed(2)}` : "—" },
{ label: "52W Low", value: sector?.fifty_two_week_low ? `$${sector.fifty_two_week_low.toFixed(2)}` : "—" },
];
const zScore = health?.altman_z;
const zColor = zScore && zScore > 2.99 ? "text-accent-green" : zScore && zScore > 1.81 ? "text-accent-yellow" : "text-accent-red";
return (
<div>
<h1 className="text-2xl font-bold mb-1">
<span className="text-accent-green">{ticker}</span> Overview
</h1>
{sector?.current_price && (
<p className="text-3xl font-mono font-bold text-text-primary mb-6">
${sector.current_price.toFixed(2)}
</p>
)}
{/* Key Metrics Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
{metrics.map((m) => (
<div key={m.label} className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">{m.label}</div>
<div className="text-text-primary font-semibold">{m.value}</div>
</div>
))}
</div>
{/* Health Section */}
<div className="grid grid-cols-2 gap-4">
{/* Altman Z-Score */}
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Altman Z-Score</h3>
<div className={`text-4xl font-mono font-bold ${zColor}`}>
{zScore?.toFixed(2) || "—"}
</div>
<div className="text-text-muted text-xs mt-2">
{zScore && zScore > 2.99 ? "Safe Zone" : zScore && zScore > 1.81 ? "Grey Zone" : "Distress Zone"}
</div>
</div>
{/* DuPont Analysis */}
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">DuPont Analysis</h3>
{health?.dupont ? (
<div className="space-y-2">
{[
{ label: "ROE", value: health.dupont.roe },
{ label: "Net Profit Margin", value: health.dupont.npm },
{ label: "Asset Turnover", value: health.dupont.asset_turnover },
{ label: "Equity Multiplier", value: health.dupont.equity_multiplier },
].map((d) => (
<div key={d.label} className="flex justify-between items-center">
<span className="text-text-muted text-sm">{d.label}</span>
<span className="text-text-primary font-mono font-semibold">
{d.value?.toFixed(2) || "—"}
</span>
</div>
))}
</div>
) : (
<div className="text-text-muted">No data</div>
)}
</div>
</div>
{/* Red Flags */}
{health?.red_flags && health.red_flags.length > 0 && (
<div className="mt-4 bg-bg-card border border-accent-red/30 rounded-lg p-5">
<h3 className="text-accent-red text-sm font-semibold mb-3">Red Flags</h3>
<ul className="space-y-1.5">
{health.red_flags.map((f, i) => (
<li key={i} className="text-text-secondary text-sm flex items-start gap-2">
<span className="text-accent-red mt-0.5"></span> {f}
</li>
))}
</ul>
</div>
)}
</div>
);
}
function LoadingState() {
return (
<div className="flex items-center justify-center h-64">
<div className="text-accent-green animate-pulse font-mono">Loading data...</div>
</div>
);
}
@@ -0,0 +1,166 @@
"use client";
import { useState, useEffect } from "react";
interface Position {
id?: string;
ticker: string;
company_name?: string;
quantity: number;
avg_price: number;
current_price?: number;
market_value?: number;
pnl?: number;
pnl_pct?: number;
}
export default function PortfolioPage() {
const [positions, setPositions] = useState<Position[]>([]);
const [form, setForm] = useState({ ticker: "", quantity: "", avg_price: "" });
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchPortfolio();
}, []);
async function fetchPortfolio() {
setLoading(true);
try {
const res = await fetch("/api/portfolio/summary");
if (res.ok) {
const data = await res.json();
setPositions(data.positions || []);
} else {
// fallback: try basic positions list
const res2 = await fetch("/api/portfolio/positions");
if (res2.ok) {
const data2 = await res2.json();
setPositions(Array.isArray(data2) ? data2 : []);
}
}
} catch {
// Portfolio may not have data yet
}
setLoading(false);
}
async function addPosition() {
const t = form.ticker.trim().toUpperCase();
const q = parseFloat(form.quantity);
const p = parseFloat(form.avg_price);
if (!t || isNaN(q) || isNaN(p)) return;
try {
const res = await fetch("/api/portfolio/positions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticker: t, quantity: q, avg_price: p }),
});
if (res.ok) {
setForm({ ticker: "", quantity: "", avg_price: "" });
fetchPortfolio();
}
} catch {
// error
}
}
const totalValue = positions.reduce((s, p) => s + (p.market_value || p.quantity * (p.current_price || p.avg_price)), 0);
const totalCost = positions.reduce((s, p) => s + p.quantity * p.avg_price, 0);
const totalGL = totalValue - totalCost;
return (
<div>
<h1 className="text-2xl font-bold mb-6">Portfolio</h1>
{/* Summary */}
<div className="grid grid-cols-3 gap-3 mb-6">
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">Total Value</div>
<div className="text-text-primary font-mono font-bold text-xl">${totalValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
</div>
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">Total Cost</div>
<div className="text-text-primary font-mono font-bold text-xl">${totalCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
</div>
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">Total P&L</div>
<div className={`font-mono font-bold text-xl ${totalGL >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{totalGL >= 0 ? "+" : ""}${totalGL.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
</div>
{/* Add Position */}
<div className="bg-bg-card border border-border rounded-lg p-4 mb-6">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Add Position</h3>
<div className="flex gap-3">
<input
value={form.ticker}
onChange={(e) => setForm({ ...form, ticker: e.target.value })}
placeholder="Ticker"
className="bg-bg-primary border border-border rounded-md px-3 py-2 text-text-primary outline-none w-32"
/>
<input
value={form.quantity}
onChange={(e) => setForm({ ...form, quantity: e.target.value })}
placeholder="Quantity"
type="number"
className="bg-bg-primary border border-border rounded-md px-3 py-2 text-text-primary outline-none w-32"
/>
<input
value={form.avg_price}
onChange={(e) => setForm({ ...form, avg_price: e.target.value })}
placeholder="Avg Price"
type="number"
className="bg-bg-primary border border-border rounded-md px-3 py-2 text-text-primary outline-none w-32"
/>
<button onClick={addPosition} className="bg-accent-green text-bg-primary px-5 py-2 rounded-md font-semibold hover:opacity-90 transition-opacity">
Add
</button>
</div>
</div>
{/* Positions Table */}
{positions.length > 0 ? (
<div className="bg-bg-card border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
{["Ticker", "Qty", "Avg Price", "Price", "Value", "P&L", "P&L %"].map((h) => (
<th key={h} className="text-left px-4 py-3 text-text-muted font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{positions.map((p, i) => {
const price = p.current_price || p.avg_price;
const value = p.market_value || p.quantity * price;
const gl = p.pnl ?? (value - p.quantity * p.avg_price);
const glPct = p.pnl_pct ?? ((price / p.avg_price - 1) * 100);
return (
<tr key={p.id || `${p.ticker}-${i}`} className="border-b border-border/50 hover:bg-bg-hover/30">
<td className="px-4 py-2.5 font-mono font-semibold text-accent-green">{p.ticker}</td>
<td className="px-4 py-2.5 font-mono text-text-primary">{p.quantity}</td>
<td className="px-4 py-2.5 font-mono text-text-primary">${p.avg_price.toFixed(2)}</td>
<td className="px-4 py-2.5 font-mono text-text-primary">${price.toFixed(2)}</td>
<td className="px-4 py-2.5 font-mono text-text-primary">${value.toLocaleString(undefined, { minimumFractionDigits: 2 })}</td>
<td className={`px-4 py-2.5 font-mono ${gl >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{gl >= 0 ? "+" : ""}${gl.toFixed(2)}
</td>
<td className={`px-4 py-2.5 font-mono ${glPct >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{glPct >= 0 ? "+" : ""}{glPct.toFixed(1)}%
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : !loading ? (
<div className="bg-bg-card border border-border rounded-lg p-8 text-center text-text-muted">
No positions yet. Add your first position above.
</div>
) : null}
</div>
);
}
@@ -0,0 +1,151 @@
"use client";
import { useEffect, useState } from "react";
import { useTicker } from "../lib/use-ticker";
interface PiotroskiData {
score?: number;
details?: Record<string, { pass: boolean; value: number }>;
}
interface RadarData {
roe?: number;
roa?: number;
gross_margin?: number;
current_ratio?: number;
revenue_growth?: number;
}
export default function ResearchPage() {
const { ticker } = useTicker();
const [piotroski, setPiotroski] = useState<PiotroskiData | null>(null);
const [radar, setRadar] = useState<RadarData | null>(null);
const [aiAnalysis, setAiAnalysis] = useState<string>("");
const [aiLoading, setAiLoading] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
Promise.all([
fetch(`/api/market/piotroski/${ticker}`).then((r) => r.ok ? r.json() : null),
fetch(`/api/market/radar/${ticker}`).then((r) => r.ok ? r.json() : null),
]).then(([p, r]) => {
setPiotroski(p);
setRadar(r);
setLoading(false);
}).catch(() => setLoading(false));
}, [ticker]);
async function runAiAnalysis() {
const apiKey = localStorage.getItem("atlas_gemini_key") || "";
if (!apiKey) {
setAiAnalysis("Please set your Gemini API key in Settings first.");
return;
}
setAiLoading(true);
try {
const res = await fetch("/api/analysis/strategy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticker, question: `Comprehensive research analysis of ${ticker}: competitive position, growth catalysts, and risks`, api_key: apiKey }),
});
if (res.ok) {
const data = await res.json();
setAiAnalysis(typeof data === "string" ? data : data.analysis || JSON.stringify(data));
} else {
setAiAnalysis("API error. Check your Gemini key in Settings.");
}
} catch {
setAiAnalysis("Connection error.");
}
setAiLoading(false);
}
if (loading) return <div className="flex items-center justify-center h-64"><div className="text-accent-green animate-pulse font-mono">Loading...</div></div>;
const fScore = piotroski?.score ?? 0;
const fColor = fScore >= 7 ? "text-accent-green" : fScore >= 4 ? "text-accent-yellow" : "text-accent-red";
const radarMetrics = radar ? [
{ label: "ROE", value: radar.roe, max: 30 },
{ label: "ROA", value: radar.roa, max: 20 },
{ label: "Gross Margin", value: radar.gross_margin, max: 100 },
{ label: "Current Ratio", value: radar.current_ratio, max: 3 },
{ label: "Revenue Growth", value: radar.revenue_growth, max: 50 },
] : [];
return (
<div>
<h1 className="text-2xl font-bold mb-6">
<span className="text-accent-green">{ticker}</span> Research
</h1>
<div className="grid grid-cols-2 gap-4 mb-6">
{/* Piotroski F-Score */}
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Piotroski F-Score</h3>
<div className={`text-5xl font-mono font-bold ${fColor}`}>{fScore}/9</div>
<div className="text-text-muted text-xs mt-2">
{fScore >= 7 ? "Strong" : fScore >= 4 ? "Moderate" : "Weak"} financial strength
</div>
{piotroski?.details && (
<div className="mt-4 space-y-1.5">
{Object.entries(piotroski.details).map(([key, val]) => (
<div key={key} className="flex justify-between text-sm">
<span className="text-text-muted">{key}</span>
<span className={val.pass ? "text-accent-green" : "text-accent-red"}>
{val.pass ? "✓" : "✗"}
</span>
</div>
))}
</div>
)}
</div>
{/* Financial Radar */}
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Financial Radar</h3>
{radarMetrics.length > 0 ? (
<div className="space-y-3">
{radarMetrics.map((m) => {
const pct = m.value != null ? Math.min((m.value / m.max) * 100, 100) : 0;
const color = pct > 66 ? "bg-accent-green" : pct > 33 ? "bg-accent-yellow" : "bg-accent-red";
return (
<div key={m.label}>
<div className="flex justify-between text-sm mb-1">
<span className="text-text-muted">{m.label}</span>
<span className="text-text-primary font-mono">{m.value?.toFixed(1) ?? "—"}%</span>
</div>
<div className="h-2 bg-bg-primary rounded-full overflow-hidden">
<div className={`h-full rounded-full ${color} transition-all`} style={{ width: `${pct}%` }} />
</div>
</div>
);
})}
</div>
) : (
<div className="text-text-muted">No data available</div>
)}
</div>
</div>
{/* AI Analysis */}
<div className="bg-bg-card border border-border rounded-lg p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-text-secondary text-sm font-semibold">AI Research Analysis</h3>
<button
onClick={runAiAnalysis}
disabled={aiLoading}
className="bg-accent-green text-bg-primary px-4 py-1.5 rounded-md text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-50"
>
{aiLoading ? "Analyzing..." : "Run Analysis"}
</button>
</div>
{aiAnalysis ? (
<pre className="text-text-primary text-sm whitespace-pre-wrap leading-relaxed font-sans">{aiAnalysis}</pre>
) : (
<div className="text-text-muted text-sm">Click &quot;Run Analysis&quot; to generate AI-powered research report.</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,92 @@
"use client";
import { useState, useEffect } from "react";
const KEYS = [
{ id: "atlas_gemini_key", label: "Gemini API Key", placeholder: "AIza..." },
{ id: "atlas_openai_key", label: "OpenAI API Key", placeholder: "sk-..." },
{ id: "atlas_anthropic_key", label: "Anthropic API Key", placeholder: "sk-ant-..." },
];
export default function SettingsPage() {
const [values, setValues] = useState<Record<string, string>>({});
const [saved, setSaved] = useState(false);
const [backendStatus, setBackendStatus] = useState<"checking" | "ok" | "error">("checking");
useEffect(() => {
// Load from localStorage
const loaded: Record<string, string> = {};
KEYS.forEach((k) => {
loaded[k.id] = localStorage.getItem(k.id) || "";
});
setValues(loaded);
// Check backend health
fetch("/api/health")
.then((r) => r.ok ? setBackendStatus("ok") : setBackendStatus("error"))
.catch(() => setBackendStatus("error"));
}, []);
function handleSave() {
Object.entries(values).forEach(([key, val]) => {
if (val) localStorage.setItem(key, val);
else localStorage.removeItem(key);
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
return (
<div className="max-w-2xl">
<h1 className="text-2xl font-bold mb-6">Settings</h1>
{/* Backend Status */}
<div className="bg-bg-card border border-border rounded-lg p-4 mb-6">
<h3 className="text-text-secondary text-sm font-semibold mb-3">System Status</h3>
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${backendStatus === "ok" ? "bg-accent-green" : backendStatus === "error" ? "bg-accent-red" : "bg-accent-yellow animate-pulse"}`} />
<span className="text-text-primary text-sm">
Backend API: {backendStatus === "ok" ? "Connected" : backendStatus === "error" ? "Disconnected" : "Checking..."}
</span>
</div>
</div>
{/* API Keys */}
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
<h3 className="text-text-secondary text-sm font-semibold mb-4">API Keys</h3>
<div className="space-y-4">
{KEYS.map((k) => (
<div key={k.id}>
<label className="text-text-muted text-sm mb-1.5 block">{k.label}</label>
<div className="flex items-center gap-3">
<input
type="password"
value={values[k.id] || ""}
onChange={(e) => setValues({ ...values, [k.id]: e.target.value })}
placeholder={k.placeholder}
className="flex-1 bg-bg-primary border border-border rounded-md px-3 py-2 text-text-primary outline-none focus:border-accent-green transition-colors"
/>
<div className={`w-2.5 h-2.5 rounded-full ${values[k.id] ? "bg-accent-green" : "bg-text-muted"}`} />
</div>
</div>
))}
</div>
<button
onClick={handleSave}
className="mt-5 bg-accent-green text-bg-primary px-6 py-2 rounded-md font-semibold hover:opacity-90 transition-opacity"
>
{saved ? "Saved!" : "Save Keys"}
</button>
</div>
{/* Info */}
<div className="bg-bg-card border border-border rounded-lg p-4">
<h3 className="text-text-secondary text-sm font-semibold mb-2">About</h3>
<div className="text-text-muted text-sm space-y-1">
<p>ATLAS Terminal v2.0 Advanced Trading & Liquidity Analysis System</p>
<p>API keys are stored locally in your browser. They are never sent to our servers.</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,282 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useTicker } from "../lib/use-ticker";
interface Indicators {
ticker: string;
current_price: number;
rsi_14: number;
sma: { sma_20: number; sma_50: number; sma_200: number | null };
ema: { ema_12: number; ema_26: number };
macd: { macd: number; signal: number; histogram: number };
bollinger_bands: { upper: number; middle: number; lower: number };
atr_14: number;
}
interface ChartBar {
time: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
}
interface FibLevels {
ticker: string;
high_52w: number;
low_52w: number;
current_price: number;
levels: Record<string, number>;
}
export default function TechnicalPage() {
const { ticker } = useTicker();
const [indicators, setIndicators] = useState<Indicators | null>(null);
const [bars, setBars] = useState<ChartBar[]>([]);
const [fib, setFib] = useState<FibLevels | null>(null);
const [loading, setLoading] = useState(true);
const [period, setPeriod] = useState("6mo");
const chartRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setLoading(true);
Promise.all([
fetch(`/api/technical/${ticker}/indicators`).then((r) => r.ok ? r.json() : null),
fetch(`/api/technical/${ticker}/chart-data?period=${period}`).then((r) => r.ok ? r.json() : null),
fetch(`/api/technical/${ticker}/fibonacci`).then((r) => r.ok ? r.json() : null),
]).then(([ind, chart, fibData]) => {
setIndicators(ind);
setBars(chart?.bars || []);
setFib(fibData);
setLoading(false);
}).catch(() => setLoading(false));
}, [ticker, period]);
// Render chart using lightweight-charts
useEffect(() => {
if (!chartRef.current || bars.length === 0) return;
let chart: any = null;
(async () => {
try {
const { createChart } = await import("lightweight-charts");
chartRef.current!.innerHTML = "";
chart = createChart(chartRef.current!, {
width: chartRef.current!.clientWidth,
height: 400,
layout: { background: { color: "#1A1A26" }, textColor: "#9CA3AF" },
grid: { vertLines: { color: "#2A2A3A" }, horzLines: { color: "#2A2A3A" } },
crosshair: { mode: 0 },
timeScale: { borderColor: "#2A2A3A" },
});
const candlestickSeries = chart.addCandlestickSeries({
upColor: "#00D4AA",
downColor: "#FF4757",
borderUpColor: "#00D4AA",
borderDownColor: "#FF4757",
wickUpColor: "#00D4AA",
wickDownColor: "#FF4757",
});
candlestickSeries.setData(bars);
const volumeSeries = chart.addHistogramSeries({
priceFormat: { type: "volume" },
priceScaleId: "",
});
volumeSeries.priceScale().applyOptions({
scaleMargins: { top: 0.8, bottom: 0 },
});
volumeSeries.setData(
bars.map((b) => ({
time: b.time,
value: b.volume,
color: b.close >= b.open ? "rgba(0,212,170,0.3)" : "rgba(255,71,87,0.3)",
}))
);
chart.timeScale().fitContent();
const handleResize = () => {
if (chartRef.current) chart.applyOptions({ width: chartRef.current.clientWidth });
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
} catch {
// lightweight-charts not available
}
})();
return () => { if (chart) chart.remove(); };
}, [bars]);
if (loading) return <div className="flex items-center justify-center h-64"><div className="text-accent-green animate-pulse font-mono">Loading...</div></div>;
const rsiColor = indicators?.rsi_14
? indicators.rsi_14 > 70 ? "text-accent-red" : indicators.rsi_14 < 30 ? "text-accent-green" : "text-text-primary"
: "text-text-primary";
const macdSignal = indicators?.macd
? indicators.macd.histogram > 0 ? "Bullish" : "Bearish"
: "—";
return (
<div>
<h1 className="text-2xl font-bold mb-6">
<span className="text-accent-green">{ticker}</span> Technical Analysis
</h1>
{/* Period Selector */}
<div className="flex gap-2 mb-4">
{["1mo", "3mo", "6mo", "1y", "2y"].map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={`px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
period === p
? "bg-accent-green text-bg-primary font-semibold"
: "bg-bg-card text-text-secondary hover:bg-bg-card/80"
}`}
>
{p.toUpperCase()}
</button>
))}
</div>
{/* Chart */}
<div className="bg-bg-card border border-border rounded-lg p-4 mb-6">
<div ref={chartRef} className="w-full" style={{ minHeight: 400 }} />
</div>
{/* Indicator Cards */}
{indicators && (
<div className="grid grid-cols-4 gap-3 mb-6">
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">Current Price</div>
<div className="text-text-primary font-mono font-bold text-xl">${indicators.current_price?.toFixed(2)}</div>
</div>
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">RSI (14)</div>
<div className={`font-mono font-bold text-xl ${rsiColor}`}>{indicators.rsi_14}</div>
<div className="text-text-muted text-xs mt-1">
{indicators.rsi_14 > 70 ? "Overbought" : indicators.rsi_14 < 30 ? "Oversold" : "Neutral"}
</div>
</div>
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">MACD Signal</div>
<div className={`font-mono font-bold text-xl ${indicators.macd.histogram > 0 ? "text-accent-green" : "text-accent-red"}`}>
{macdSignal}
</div>
<div className="text-text-muted text-xs mt-1 font-mono">H: {indicators.macd.histogram.toFixed(4)}</div>
</div>
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">ATR (14)</div>
<div className="text-text-primary font-mono font-bold text-xl">{indicators.atr_14}</div>
<div className="text-text-muted text-xs mt-1">Volatility</div>
</div>
</div>
)}
{/* Moving Averages & Bollinger */}
{indicators && (
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Moving Averages</h3>
<div className="space-y-2">
{[
{ label: "SMA 20", value: indicators.sma.sma_20, signal: indicators.current_price > indicators.sma.sma_20 },
{ label: "SMA 50", value: indicators.sma.sma_50, signal: indicators.current_price > indicators.sma.sma_50 },
{ label: "SMA 200", value: indicators.sma.sma_200, signal: indicators.sma.sma_200 ? indicators.current_price > indicators.sma.sma_200 : null },
{ label: "EMA 12", value: indicators.ema.ema_12, signal: indicators.current_price > indicators.ema.ema_12 },
{ label: "EMA 26", value: indicators.ema.ema_26, signal: indicators.current_price > indicators.ema.ema_26 },
].map((ma) => (
<div key={ma.label} className="flex justify-between items-center text-sm">
<span className="text-text-muted">{ma.label}</span>
<div className="flex items-center gap-3">
<span className="text-text-primary font-mono">{ma.value != null ? `$${ma.value.toFixed(2)}` : "—"}</span>
<span className={`text-xs font-semibold px-2 py-0.5 rounded ${
ma.signal === true ? "bg-accent-green/20 text-accent-green" :
ma.signal === false ? "bg-accent-red/20 text-accent-red" : "bg-bg-primary text-text-muted"
}`}>
{ma.signal === true ? "ABOVE" : ma.signal === false ? "BELOW" : "N/A"}
</span>
</div>
</div>
))}
</div>
</div>
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Bollinger Bands (20, 2)</h3>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-text-muted">Upper Band</span>
<span className="text-accent-red font-mono">${indicators.bollinger_bands.upper.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-text-muted">Middle Band</span>
<span className="text-accent-yellow font-mono">${indicators.bollinger_bands.middle.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-text-muted">Lower Band</span>
<span className="text-accent-green font-mono">${indicators.bollinger_bands.lower.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm border-t border-border pt-3">
<span className="text-text-muted">BB Width</span>
<span className="text-text-primary font-mono">
{((indicators.bollinger_bands.upper - indicators.bollinger_bands.lower) / indicators.bollinger_bands.middle * 100).toFixed(2)}%
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-text-muted">%B Position</span>
<span className="text-text-primary font-mono">
{((indicators.current_price - indicators.bollinger_bands.lower) / (indicators.bollinger_bands.upper - indicators.bollinger_bands.lower) * 100).toFixed(1)}%
</span>
</div>
</div>
{/* MACD Detail */}
<h3 className="text-text-secondary text-sm font-semibold mb-3 mt-5">MACD (12, 26, 9)</h3>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-text-muted">MACD Line</span>
<span className="text-text-primary font-mono">{indicators.macd.macd.toFixed(4)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-text-muted">Signal Line</span>
<span className="text-text-primary font-mono">{indicators.macd.signal.toFixed(4)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-text-muted">Histogram</span>
<span className={`font-mono ${indicators.macd.histogram > 0 ? "text-accent-green" : "text-accent-red"}`}>
{indicators.macd.histogram.toFixed(4)}
</span>
</div>
</div>
</div>
</div>
)}
{/* Fibonacci Levels */}
{fib && (
<div className="bg-bg-card border border-border rounded-lg p-5">
<h3 className="text-text-secondary text-sm font-semibold mb-3">Fibonacci Retracement</h3>
<div className="grid grid-cols-7 gap-3">
{Object.entries(fib.levels).map(([level, price]) => {
const isNear = Math.abs(price - fib.current_price) / fib.current_price < 0.02;
return (
<div key={level} className={`text-center p-3 rounded-lg ${isNear ? "bg-accent-green/10 border border-accent-green" : "bg-bg-primary"}`}>
<div className="text-text-muted text-xs mb-1">{level}</div>
<div className={`font-mono text-sm font-semibold ${isNear ? "text-accent-green" : "text-text-primary"}`}>
${price.toFixed(2)}
</div>
</div>
);
})}
</div>
<div className="mt-3 text-text-muted text-xs font-mono">
52W Range: ${fib.low_52w.toFixed(2)} ${fib.high_52w.toFixed(2)} | Current: ${fib.current_price.toFixed(2)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,512 @@
"use client";
import { useEffect, useState } from "react";
import { useTicker } from "../lib/use-ticker";
interface DCFInputs {
fcf?: number;
total_debt?: number;
cash?: number;
shares?: number;
}
interface DCFResult {
scenarios?: {
bull?: { intrinsic_value: number; upside: number };
base?: { intrinsic_value: number; upside: number };
bear?: { intrinsic_value: number; upside: number };
};
}
interface Consensus {
target_mean?: number;
target_high?: number;
target_low?: number;
recommendation?: string;
}
interface SensitivityData {
wacc_values: number[];
tg_values: number[];
matrix: (number | null)[][];
}
interface TornadoItem {
variable: string;
low: number;
high: number;
base: number;
}
interface MonteCarloData {
percentile_10: number | null;
median: number | null;
percentile_90: number | null;
mean: number | null;
prob_above_current: number | null;
current_price: number | null;
histogram?: { counts: number[]; bin_edges: number[] };
}
type ValuationTab = "dcf" | "sensitivity" | "montecarlo" | "tornado" | "reverse";
export default function ValuationPage() {
const { ticker } = useTicker();
const [inputs, setInputs] = useState<DCFInputs | null>(null);
const [consensus, setConsensus] = useState<Consensus | null>(null);
const [dcfResult, setDcfResult] = useState<DCFResult | null>(null);
const [wacc, setWacc] = useState(10);
const [terminalGrowth, setTerminalGrowth] = useState(2.5);
const [fcfGrowth, setFcfGrowth] = useState(8);
const [loading, setLoading] = useState(true);
const [dcfLoading, setDcfLoading] = useState(false);
const [activeTab, setActiveTab] = useState<ValuationTab>("dcf");
// Advanced models state
const [sensitivity, setSensitivity] = useState<SensitivityData | null>(null);
const [tornado, setTornado] = useState<TornadoItem[]>([]);
const [monteCarlo, setMonteCarlo] = useState<MonteCarloData | null>(null);
const [reverseDCF, setReverseDCF] = useState<{ implied_growth: number | null; current_price: number | null } | null>(null);
const [advLoading, setAdvLoading] = useState(false);
useEffect(() => {
setLoading(true);
setDcfResult(null);
setSensitivity(null);
setTornado([]);
setMonteCarlo(null);
setReverseDCF(null);
Promise.all([
fetch(`/api/valuation/dcf-inputs/${ticker}`).then((r) => r.ok ? r.json() : null),
fetch(`/api/valuation/consensus/${ticker}`).then((r) => r.ok ? r.json() : null),
fetch(`/api/valuation/smart-defaults/${ticker}`).then((r) => r.ok ? r.json() : null),
]).then(([i, c, d]) => {
setInputs(i);
setConsensus(c);
if (d?.wacc) setWacc(d.wacc);
if (d?.terminal_growth) setTerminalGrowth(d.terminal_growth);
if (d?.fcf_growth) setFcfGrowth(d.fcf_growth);
setLoading(false);
}).catch(() => setLoading(false));
}, [ticker]);
async function runDCF() {
if (!inputs) return;
setDcfLoading(true);
try {
const res = await fetch("/api/valuation/dcf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ticker,
base_fcf: inputs.fcf,
total_debt: inputs.total_debt,
cash: inputs.cash,
shares: inputs.shares,
wacc: wacc / 100,
terminal_growth: terminalGrowth / 100,
fcf_growth_rate: fcfGrowth / 100,
}),
});
if (res.ok) setDcfResult(await res.json());
} catch { /* */ }
setDcfLoading(false);
}
async function runAdvancedModel(tab: ValuationTab) {
if (!inputs?.fcf || !inputs?.shares) return;
setAdvLoading(true);
const body = {
ticker,
fcf: inputs.fcf,
total_debt: inputs.total_debt || 0,
cash: inputs.cash || 0,
shares: inputs.shares,
wacc: wacc / 100,
terminal_growth: terminalGrowth / 100,
fcf_growth: fcfGrowth / 100,
};
try {
if (tab === "sensitivity") {
const res = await fetch("/api/valuation/sensitivity", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) setSensitivity(await res.json());
} else if (tab === "tornado") {
const res = await fetch("/api/valuation/tornado", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) {
const data = await res.json();
setTornado(data.data || []);
}
} else if (tab === "montecarlo") {
const res = await fetch("/api/valuation/monte-carlo", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...body,
wacc_mean: wacc / 100,
wacc_std: 0.015,
growth_mean: fcfGrowth / 100,
growth_std: 0.03,
term_growth: terminalGrowth / 100,
n_simulations: 5000,
}),
});
if (res.ok) setMonteCarlo(await res.json());
} else if (tab === "reverse") {
const res = await fetch("/api/valuation/reverse-dcf", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) setReverseDCF(await res.json());
}
} catch { /* */ }
setAdvLoading(false);
}
if (loading) return <div className="flex items-center justify-center h-64"><div className="text-accent-green animate-pulse font-mono">Loading...</div></div>;
return (
<div>
<h1 className="text-2xl font-bold mb-6">
<span className="text-accent-green">{ticker}</span> Valuation
</h1>
{/* Analyst Consensus */}
{consensus && (
<div className="grid grid-cols-4 gap-3 mb-6">
{[
{ label: "Target Mean", value: consensus.target_mean ? `$${consensus.target_mean.toFixed(2)}` : "—" },
{ label: "Target High", value: consensus.target_high ? `$${consensus.target_high.toFixed(2)}` : "—" },
{ label: "Target Low", value: consensus.target_low ? `$${consensus.target_low.toFixed(2)}` : "—" },
{ label: "Recommendation", value: consensus.recommendation?.toUpperCase() || "—" },
].map((m) => (
<div key={m.label} className="bg-bg-card border border-border rounded-lg p-4">
<div className="text-text-muted text-xs mb-1">{m.label}</div>
<div className="text-text-primary font-semibold font-mono">{m.value}</div>
</div>
))}
</div>
)}
{/* Tab Navigation */}
<div className="flex gap-1 mb-5 bg-bg-card border border-border rounded-lg p-1">
{[
{ key: "dcf" as ValuationTab, label: "DCF Model" },
{ key: "sensitivity" as ValuationTab, label: "Sensitivity" },
{ key: "montecarlo" as ValuationTab, label: "Monte Carlo" },
{ key: "tornado" as ValuationTab, label: "Tornado" },
{ key: "reverse" as ValuationTab, label: "Reverse DCF" },
].map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex-1 px-3 py-2 rounded-md text-sm font-mono transition-all ${
activeTab === tab.key
? "bg-accent-green text-bg-primary font-semibold"
: "text-text-secondary hover:text-text-primary"
}`}
>
{tab.label}
</button>
))}
</div>
{/* DCF Tab */}
{activeTab === "dcf" && (
<>
<div className="bg-bg-card border border-border rounded-lg p-5 mb-6">
<h3 className="text-text-secondary text-sm font-semibold mb-4">DCF Calculator</h3>
{inputs && (
<div className="grid grid-cols-4 gap-3 mb-5 text-sm">
{[
{ label: "Free Cash Flow", value: inputs.fcf ? `$${(inputs.fcf / 1e9).toFixed(2)}B` : "—" },
{ label: "Total Debt", value: inputs.total_debt ? `$${(inputs.total_debt / 1e9).toFixed(2)}B` : "—" },
{ label: "Cash", value: inputs.cash ? `$${(inputs.cash / 1e9).toFixed(2)}B` : "—" },
{ label: "Shares Out", value: inputs.shares ? `${(inputs.shares / 1e9).toFixed(2)}B` : "—" },
].map((m) => (
<div key={m.label} className="bg-bg-primary rounded-md p-3">
<div className="text-text-muted text-xs">{m.label}</div>
<div className="text-text-primary font-mono font-semibold mt-1">{m.value}</div>
</div>
))}
</div>
)}
<div className="grid grid-cols-3 gap-6 mb-5">
<SliderInput label="WACC" value={wacc} onChange={setWacc} min={5} max={20} step={0.5} suffix="%" />
<SliderInput label="Terminal Growth" value={terminalGrowth} onChange={setTerminalGrowth} min={0} max={5} step={0.5} suffix="%" />
<SliderInput label="FCF Growth" value={fcfGrowth} onChange={setFcfGrowth} min={0} max={30} step={1} suffix="%" />
</div>
<button
onClick={runDCF}
disabled={dcfLoading || !inputs}
className="bg-accent-green text-bg-primary px-6 py-2 rounded-md font-semibold hover:opacity-90 transition-opacity disabled:opacity-50"
>
{dcfLoading ? "Calculating..." : "Run DCF"}
</button>
</div>
{dcfResult?.scenarios && (
<div className="grid grid-cols-3 gap-4">
{(["bear", "base", "bull"] as const).map((scenario) => {
const s = dcfResult.scenarios?.[scenario];
if (!s) return null;
const color = scenario === "bull" ? "border-accent-green" : scenario === "bear" ? "border-accent-red" : "border-accent-blue";
const textColor = scenario === "bull" ? "text-accent-green" : scenario === "bear" ? "text-accent-red" : "text-accent-blue";
return (
<div key={scenario} className={`bg-bg-card border-2 ${color} rounded-lg p-5`}>
<h4 className={`${textColor} text-sm font-semibold mb-2 uppercase`}>{scenario} Case</h4>
<div className="text-3xl font-mono font-bold text-text-primary">
${s.intrinsic_value?.toFixed(2)}
</div>
<div className={`text-sm mt-1 font-mono ${s.upside >= 0 ? "text-accent-green" : "text-accent-red"}`}>
{s.upside >= 0 ? "+" : ""}{s.upside?.toFixed(1)}% upside
</div>
</div>
);
})}
</div>
)}
</>
)}
{/* Sensitivity Tab */}
{activeTab === "sensitivity" && (
<div className="bg-bg-card border border-border rounded-lg p-5">
<div className="flex justify-between items-center mb-4">
<h3 className="text-text-secondary text-sm font-semibold">WACC vs Terminal Growth Sensitivity</h3>
<button
onClick={() => runAdvancedModel("sensitivity")}
disabled={advLoading || !inputs?.fcf}
className="bg-accent-green text-bg-primary px-4 py-1.5 rounded-md text-sm font-semibold hover:opacity-90 disabled:opacity-50"
>
{advLoading ? "Computing..." : "Generate"}
</button>
</div>
{sensitivity && (
<div className="overflow-x-auto">
<table className="w-full text-xs font-mono">
<thead>
<tr>
<th className="p-2 text-text-muted text-left">WACC \ TG</th>
{sensitivity.tg_values.map((tg) => (
<th key={tg} className="p-2 text-text-muted text-right">{tg.toFixed(1)}%</th>
))}
</tr>
</thead>
<tbody>
{sensitivity.wacc_values.map((w, ri) => (
<tr key={w} className="border-t border-border/30">
<td className="p-2 text-text-muted font-semibold">{w.toFixed(1)}%</td>
{sensitivity.matrix[ri].map((val, ci) => {
const isCenter = ri === Math.floor(sensitivity.wacc_values.length / 2) && ci === Math.floor(sensitivity.tg_values.length / 2);
return (
<td
key={ci}
className={`p-2 text-right ${
isCenter ? "bg-accent-green/20 text-accent-green font-bold" :
val != null && val > 0 ? "text-text-primary" : "text-text-muted"
}`}
>
{val != null ? `$${val.toFixed(0)}` : "—"}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
{!sensitivity && !advLoading && (
<div className="text-text-muted text-center py-12 text-sm">Click Generate to build the sensitivity matrix</div>
)}
</div>
)}
{/* Monte Carlo Tab */}
{activeTab === "montecarlo" && (
<div className="bg-bg-card border border-border rounded-lg p-5">
<div className="flex justify-between items-center mb-4">
<h3 className="text-text-secondary text-sm font-semibold">Monte Carlo DCF (5,000 simulations)</h3>
<button
onClick={() => runAdvancedModel("montecarlo")}
disabled={advLoading || !inputs?.fcf}
className="bg-accent-green text-bg-primary px-4 py-1.5 rounded-md text-sm font-semibold hover:opacity-90 disabled:opacity-50"
>
{advLoading ? "Simulating..." : "Run Simulation"}
</button>
</div>
{monteCarlo && monteCarlo.median != null && (
<>
<div className="grid grid-cols-5 gap-3 mb-5">
{[
{ label: "10th Pct", value: `$${monteCarlo.percentile_10?.toFixed(2)}`, color: "text-accent-red" },
{ label: "Median", value: `$${monteCarlo.median?.toFixed(2)}`, color: "text-accent-yellow" },
{ label: "Mean", value: `$${monteCarlo.mean?.toFixed(2)}`, color: "text-accent-blue" },
{ label: "90th Pct", value: `$${monteCarlo.percentile_90?.toFixed(2)}`, color: "text-accent-green" },
{ label: "P(> Current)", value: monteCarlo.prob_above_current != null ? `${monteCarlo.prob_above_current}%` : "—", color: "text-text-primary" },
].map((m) => (
<div key={m.label} className="bg-bg-primary rounded-lg p-3 text-center">
<div className="text-text-muted text-xs mb-1">{m.label}</div>
<div className={`font-mono font-bold text-lg ${m.color}`}>{m.value}</div>
</div>
))}
</div>
{/* Histogram */}
{monteCarlo.histogram && (
<div className="mt-4">
<div className="flex items-end gap-px h-40">
{monteCarlo.histogram.counts.map((count, i) => {
const maxCount = Math.max(...monteCarlo.histogram!.counts);
const height = maxCount > 0 ? (count / maxCount) * 100 : 0;
const binMid = (monteCarlo.histogram!.bin_edges[i] + monteCarlo.histogram!.bin_edges[i + 1]) / 2;
const isAboveCurrent = monteCarlo.current_price != null && binMid > monteCarlo.current_price;
return (
<div
key={i}
className={`flex-1 rounded-t-sm ${isAboveCurrent ? "bg-accent-green" : "bg-accent-red"}`}
style={{ height: `${Math.max(height, 1)}%` }}
title={`$${monteCarlo.histogram!.bin_edges[i].toFixed(0)}-$${monteCarlo.histogram!.bin_edges[i + 1].toFixed(0)}: ${count}`}
/>
);
})}
</div>
<div className="flex justify-between text-text-muted text-xs font-mono mt-1">
<span>${monteCarlo.histogram.bin_edges[0].toFixed(0)}</span>
{monteCarlo.current_price && <span className="text-accent-yellow">Current: ${monteCarlo.current_price.toFixed(0)}</span>}
<span>${monteCarlo.histogram.bin_edges[monteCarlo.histogram.bin_edges.length - 1].toFixed(0)}</span>
</div>
</div>
)}
</>
)}
{!monteCarlo && !advLoading && (
<div className="text-text-muted text-center py-12 text-sm">Click Run Simulation to perform Monte Carlo analysis</div>
)}
</div>
)}
{/* Tornado Tab */}
{activeTab === "tornado" && (
<div className="bg-bg-card border border-border rounded-lg p-5">
<div className="flex justify-between items-center mb-4">
<h3 className="text-text-secondary text-sm font-semibold">Tornado Chart Variable Impact (±10%)</h3>
<button
onClick={() => runAdvancedModel("tornado")}
disabled={advLoading || !inputs?.fcf}
className="bg-accent-green text-bg-primary px-4 py-1.5 rounded-md text-sm font-semibold hover:opacity-90 disabled:opacity-50"
>
{advLoading ? "Computing..." : "Generate"}
</button>
</div>
{tornado.length > 0 && (
<div className="space-y-3">
{tornado.map((item) => {
const range = item.high - item.low;
const maxRange = Math.max(...tornado.map((t) => t.high - t.low));
const widthPct = maxRange > 0 ? (range / maxRange) * 100 : 0;
const baseOffset = maxRange > 0 ? ((item.base - item.low) / maxRange) * 100 : 50;
return (
<div key={item.variable} className="flex items-center gap-3">
<div className="w-28 text-right text-text-muted text-sm shrink-0">{item.variable}</div>
<div className="flex-1 relative h-8 bg-bg-primary rounded overflow-hidden">
<div
className="absolute h-full bg-gradient-to-r from-accent-red via-accent-yellow to-accent-green rounded opacity-80"
style={{ width: `${widthPct}%`, left: 0 }}
/>
<div
className="absolute top-0 h-full w-0.5 bg-text-primary z-10"
style={{ left: `${baseOffset}%` }}
/>
</div>
<div className="w-32 shrink-0 flex justify-between text-xs font-mono">
<span className="text-accent-red">${item.low.toFixed(0)}</span>
<span className="text-accent-green">${item.high.toFixed(0)}</span>
</div>
</div>
);
})}
<div className="text-text-muted text-xs mt-2 font-mono text-center">
Base value: ${tornado[0]?.base.toFixed(2)} | Sensitivity ±10% of each variable
</div>
</div>
)}
{tornado.length === 0 && !advLoading && (
<div className="text-text-muted text-center py-12 text-sm">Click Generate to build the tornado chart</div>
)}
</div>
)}
{/* Reverse DCF Tab */}
{activeTab === "reverse" && (
<div className="bg-bg-card border border-border rounded-lg p-5">
<div className="flex justify-between items-center mb-4">
<h3 className="text-text-secondary text-sm font-semibold">Reverse DCF Implied Growth Rate</h3>
<button
onClick={() => runAdvancedModel("reverse")}
disabled={advLoading || !inputs?.fcf}
className="bg-accent-green text-bg-primary px-4 py-1.5 rounded-md text-sm font-semibold hover:opacity-90 disabled:opacity-50"
>
{advLoading ? "Computing..." : "Calculate"}
</button>
</div>
{reverseDCF && (
<div className="text-center py-8">
<div className="text-text-muted text-sm mb-2">The market is pricing in an annual FCF growth rate of:</div>
<div className={`text-5xl font-mono font-bold ${
reverseDCF.implied_growth != null && reverseDCF.implied_growth >= 0 ? "text-accent-green" : "text-accent-red"
}`}>
{reverseDCF.implied_growth != null ? `${reverseDCF.implied_growth.toFixed(2)}%` : "N/A"}
</div>
<div className="text-text-muted text-sm mt-3 font-mono">
Current Price: ${reverseDCF.current_price?.toFixed(2)} | WACC: {wacc}% | Terminal Growth: {terminalGrowth}%
</div>
{reverseDCF.implied_growth != null && (
<div className="mt-4 text-sm">
<span className="text-text-muted">Your assumption: </span>
<span className="text-accent-blue font-mono font-bold">{fcfGrowth}%</span>
<span className="text-text-muted"> vs Market implied: </span>
<span className={`font-mono font-bold ${reverseDCF.implied_growth >= fcfGrowth ? "text-accent-green" : "text-accent-red"}`}>
{reverseDCF.implied_growth.toFixed(2)}%
</span>
<span className="text-text-muted"> </span>
<span className={`font-semibold ${reverseDCF.implied_growth > fcfGrowth ? "text-accent-red" : "text-accent-green"}`}>
{reverseDCF.implied_growth > fcfGrowth ? "Market expects MORE growth (potentially overvalued)" : "Market expects LESS growth (potentially undervalued)"}
</span>
</div>
)}
</div>
)}
{!reverseDCF && !advLoading && (
<div className="text-text-muted text-center py-12 text-sm">Click Calculate to find the implied growth rate</div>
)}
</div>
)}
</div>
);
}
function SliderInput({ label, value, onChange, min, max, step, suffix }: {
label: string; value: number; onChange: (v: number) => void;
min: number; max: number; step: number; suffix: string;
}) {
return (
<div>
<div className="flex justify-between text-sm mb-2">
<span className="text-text-muted">{label}</span>
<span className="text-accent-green font-mono font-semibold">{value}{suffix}</span>
</div>
<input
type="range"
min={min} max={max} step={step}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="w-full accent-[#00D4AA]"
/>
</div>
);
}
@@ -0,0 +1,39 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
bg: {
primary: "#0A0A0F",
secondary: "#12121A",
card: "#1A1A26",
hover: "#252536",
},
accent: {
green: "#00D4AA",
red: "#FF4757",
yellow: "#FFD93D",
blue: "#4DA6FF",
},
text: {
primary: "#F3F4F6",
secondary: "#9CA3AF",
muted: "#6B7280",
},
border: {
DEFAULT: "#2A2A3A",
},
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
mono: ["JetBrains Mono", "monospace"],
},
},
},
plugins: [],
};
export default config;
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+21
View File
@@ -0,0 +1,21 @@
fastapi>=0.110.0
uvicorn[standard]>=0.29.0
pydantic>=2.7.0
google-generativeai>=0.8.0
anthropic>=0.39.0
openai>=1.50.0
beautifulsoup4>=4.12.0
requests>=2.31.0
pandas>=2.0.0
lxml>=4.9.0
python-dotenv>=1.0.0
yfinance>=0.2.40
yahooquery>=2.2.0
sec-edgar-downloader>=5.0.0
feedparser>=6.0.0
ta>=0.11.0
aiosqlite>=0.20.0
asyncpg>=0.30.0
pytest>=8.0.0
httpx>=0.27.0
pillow>=10.0.0
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Generate README.md for ATLAS Terminal from project metadata.
Scans the project structure and produces a fresh README with:
- Feature list derived from router files
- Tech stack from requirements.txt and package.json
- Changelog from recent git commits
Run manually or via the GitHub Actions workflow (.github/workflows/update-readme.yml).
"""
import json
import subprocess
import textwrap
from datetime import datetime
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _read_requirements() -> list[str]:
"""Return top-level package names from requirements.txt."""
req_path = PROJECT_ROOT / "requirements.txt"
if not req_path.exists():
return []
lines = req_path.read_text().splitlines()
packages = []
for line in lines:
line = line.strip()
if line and not line.startswith("#"):
name = line.split(">=")[0].split("==")[0].split("[")[0].strip()
packages.append(name)
return packages
def _read_package_json_deps() -> list[str]:
"""Return dependency names from apps/web/package.json."""
pkg_path = PROJECT_ROOT / "apps" / "web" / "package.json"
if not pkg_path.exists():
return []
try:
data = json.loads(pkg_path.read_text())
deps = list(data.get("dependencies", {}).keys())
deps += list(data.get("devDependencies", {}).keys())
return deps
except (json.JSONDecodeError, OSError):
return []
def _detect_routers() -> list[str]:
"""Return router module names from server/routers/."""
router_dir = PROJECT_ROOT / "server" / "routers"
if not router_dir.exists():
return []
return sorted(
f.stem
for f in router_dir.glob("*.py")
if f.stem != "__init__"
)
def _recent_commits(n: int = 10) -> list[str]:
"""Return the last *n* one-line commit messages."""
try:
result = subprocess.run(
["git", "log", f"-{n}", "--pretty=format:%s"],
capture_output=True, text=True, cwd=str(PROJECT_ROOT),
)
if result.returncode == 0:
return [line for line in result.stdout.splitlines() if line.strip()]
except FileNotFoundError:
pass
return []
# ---------------------------------------------------------------------------
# Template
# ---------------------------------------------------------------------------
def generate() -> str:
routers = _detect_routers()
py_deps = _read_requirements()
js_deps = _read_package_json_deps()
commits = _recent_commits(10)
feature_bullets = "\n".join(f"- **{r.replace('_', ' ').title()}**" for r in routers)
py_stack = ", ".join(py_deps[:8]) + (" ..." if len(py_deps) > 8 else "")
js_stack = ", ".join(js_deps[:8]) + (" ..." if len(js_deps) > 8 else "")
changelog = "\n".join(f"- {c}" for c in commits) if commits else "- (no commits yet)"
today = datetime.now().strftime("%Y-%m-%d")
readme = textwrap.dedent(f"""\
# ATLAS Terminal
> Personal Bloomberg-style financial terminal -- real-time market data,
> AI-powered analysis, DCF valuation, and portfolio management.
## Features
{feature_bullets}
## Tech Stack
**Backend (Python):** {py_stack}
**Frontend (Next.js):** {js_stack}
## Quick Start
```bash
# Backend
cd atlas-terminal
pip install -r requirements.txt
uvicorn server.main:app --reload --port 8000
# Frontend
cd apps/web
npm install && npm run dev
```
## Recent Changes
{changelog}
---
*Auto-generated on {today}*
""")
return readme
def main():
readme_path = PROJECT_ROOT / "README.md"
content = generate()
readme_path.write_text(content)
print(f"README.md written ({len(content)} bytes)")
if __name__ == "__main__":
main()
View File
+222
View File
@@ -0,0 +1,222 @@
"""Builds AI context from active widget data for the ATLAS Terminal chat."""
from __future__ import annotations
from typing import Any
# ---------------------------------------------------------------------------
# Widget-specific context templates
# ---------------------------------------------------------------------------
_DCF_TEMPLATE = """
## DCF Valuation Context
- Implied share price: ${implied_price:.2f}
- Current market price: ${current_price:.2f}
- Upside/Downside: {upside:+.1f}%
- WACC: {wacc:.1f}%
- Terminal growth rate: {terminal_growth:.1f}%
- FCF projections (5Y): {fcf_projections}
"""
_FINANCIALS_TEMPLATE = """
## Financial Metrics Context
- Revenue (TTM): ${revenue}
- Net income (TTM): ${net_income}
- Gross margin: {gross_margin:.1f}%
- Operating margin: {operating_margin:.1f}%
- ROE: {roe:.1f}%
- Debt/Equity: {debt_equity:.2f}
- Current ratio: {current_ratio:.2f}
"""
_TECHNICAL_TEMPLATE = """
## Technical Analysis Context
- RSI (14): {rsi:.1f}
- MACD: {macd:.4f} | Signal: {macd_signal:.4f}
- SMA 50: ${sma_50:.2f} | SMA 200: ${sma_200:.2f}
- 52-week high: ${high_52w:.2f} | Low: ${low_52w:.2f}
- Volume (avg 20d): {avg_volume}
"""
_PORTFOLIO_TEMPLATE = """
## Portfolio Context
- Total value: ${total_value:,.0f}
- Number of positions: {position_count}
- Top holdings: {top_holdings}
- Sector allocation: {sector_allocation}
"""
_FILING_TEMPLATE = """
## SEC Filing Context
- Latest filing type: {filing_type}
- Filed on: {filing_date}
- Key sections available: {sections}
"""
# ---------------------------------------------------------------------------
# Suggested questions per widget
# ---------------------------------------------------------------------------
_WIDGET_QUESTIONS: dict[str, list[str]] = {
"dcf": [
"Are the market's growth assumptions reasonable for this company?",
"What would the fair value be with a higher discount rate?",
"How sensitive is the valuation to terminal growth assumptions?",
],
"financials": [
"How do the margins compare to industry peers?",
"Is the revenue growth trend sustainable?",
"What are the key drivers behind the profitability changes?",
],
"technical": [
"What does the current technical setup suggest for the near term?",
"Is the stock overbought or oversold based on RSI?",
"Are there any notable divergences between price and momentum?",
],
"portfolio": [
"Is my sector diversification sufficient?",
"Which positions carry the most concentration risk?",
"How does my portfolio beta compare to the market?",
],
"filing": [
"What are the key risks disclosed in the latest filing?",
"Are there any notable changes in accounting policies?",
"What does management say about the competitive landscape?",
],
"news": [
"What is the overall sentiment of recent news?",
"Are there any material events that could affect the stock?",
"How might recent headlines impact the company's outlook?",
],
}
_DEFAULT_QUESTIONS: list[str] = [
"Give me a quick overview of this company's financial health.",
"What are the biggest risks facing this stock right now?",
"Should I consider adding this to my portfolio? Why or why not?",
]
# ---------------------------------------------------------------------------
# ContextBuilder
# ---------------------------------------------------------------------------
class ContextBuilder:
"""Builds AI context from active widget data.
The context is injected into the system prompt so the LLM can reference
concrete numbers when answering the user's questions about a ticker.
"""
def build_system_prompt(
self,
ticker: str,
active_widgets: list[str],
widget_data: dict[str, Any],
) -> str:
"""Build a context-aware system prompt.
Args:
ticker: The active ticker symbol (e.g. ``"AAPL"``).
active_widgets: List of widget identifiers currently visible
(e.g. ``["dcf", "financials", "technical"]``).
widget_data: A dict keyed by widget name containing the data
displayed in each widget.
Returns:
A system prompt string enriched with financial context.
"""
sections: list[str] = [
"You are ATLAS, an expert financial analyst assistant "
"integrated into the ATLAS Terminal.\n"
"You have direct access to the data the user is currently viewing.\n"
"Answer concisely with concrete numbers when available. "
"Use markdown formatting for readability.",
]
# Always include base info if available
base = widget_data.get("base", {})
if ticker:
sections.append(
f"\n## Active Ticker: {ticker.upper()}\n"
f"- Sector: {base.get('sector', 'N/A')}\n"
f"- Current price: ${base.get('current_price', 'N/A')}\n"
f"- Market cap: {base.get('market_cap', 'N/A')}\n"
)
# Append widget-specific context
for widget in active_widgets:
section = self._build_widget_section(widget, widget_data)
if section:
sections.append(section)
return "\n".join(sections)
def build_suggested_questions(
self,
active_widgets: list[str],
) -> list[str]:
"""Generate suggested questions based on active widgets.
Args:
active_widgets: List of widget identifiers currently visible.
Returns:
A list of 3-5 suggested question strings.
"""
questions: list[str] = []
for widget in active_widgets:
widget_key = widget.lower().strip()
if widget_key in _WIDGET_QUESTIONS:
# Pick the first question from each active widget
questions.append(_WIDGET_QUESTIONS[widget_key][0])
# Pad with defaults if we have fewer than 3
for q in _DEFAULT_QUESTIONS:
if len(questions) >= 5:
break
if q not in questions:
questions.append(q)
return questions[:5]
# -- private helpers ----------------------------------------------------
def _build_widget_section(
self, widget: str, widget_data: dict[str, Any]
) -> str:
"""Render context section for a specific widget.
Returns an empty string when no data is available for the widget.
"""
widget_key = widget.lower().strip()
data = widget_data.get(widget_key, {})
if not data:
return ""
try:
if widget_key == "dcf":
return _DCF_TEMPLATE.format(**data)
if widget_key == "financials":
return _FINANCIALS_TEMPLATE.format(**data)
if widget_key == "technical":
return _TECHNICAL_TEMPLATE.format(**data)
if widget_key == "portfolio":
return _PORTFOLIO_TEMPLATE.format(**data)
if widget_key == "filing":
return _FILING_TEMPLATE.format(**data)
except (KeyError, ValueError, TypeError) as exc:
# Gracefully degrade -- partial data is fine
return f"\n## {widget.title()} Context\nPartial data: {data}\n"
# Unknown widget -- dump raw data as a summary
return f"\n## {widget.title()} Context\n{data}\n"
# ---------------------------------------------------------------------------
# Singleton
# ---------------------------------------------------------------------------
context_builder = ContextBuilder()
+324
View File
@@ -0,0 +1,324 @@
"""Unified Multi-LLM router supporting Gemini, Claude, and OpenAI."""
from __future__ import annotations
import asyncio
import logging
from enum import Enum
from typing import AsyncGenerator, Optional
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
DEFAULT_MODELS: dict[str, str] = {
"gemini": "gemini-2.0-flash",
"claude": "claude-sonnet-4-20250514",
"openai": "gpt-4o-mini",
}
class LLMProvider(str, Enum):
"""Supported LLM providers."""
GEMINI = "gemini"
CLAUDE = "claude"
OPENAI = "openai"
class LLMConfig(BaseModel):
"""Configuration for a single LLM request."""
provider: LLMProvider
model: str = ""
api_key: str = ""
temperature: float = Field(default=0.3, ge=0.0, le=2.0)
max_tokens: int = Field(default=4096, ge=1, le=128_000)
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
class LLMRouter:
"""Routes requests to the appropriate LLM provider.
Register API keys via ``configure()``, then call ``generate()`` or
``stream()`` with an optional ``LLMConfig``. When no config is given the
router auto-selects a provider based on prompt length:
* < 5 000 chars -> Gemini (fast)
* > 10 000 chars -> Claude (long-context)
* fallback -> OpenAI
"""
def __init__(self) -> None:
self._providers: dict[LLMProvider, str] = {}
# -- configuration ------------------------------------------------------
def configure(self, provider: LLMProvider, api_key: str) -> None:
"""Register an API key for *provider*."""
self._providers[provider] = api_key
logger.info("LLM provider configured: %s", provider.value)
def get_available_providers(self) -> list[LLMProvider]:
"""Return the list of providers that have an API key configured."""
return list(self._providers.keys())
# -- public interface ---------------------------------------------------
def _resolve_config(
self, prompt: str, config: Optional[LLMConfig]
) -> LLMConfig:
"""Return a fully-resolved ``LLMConfig``.
If *config* is ``None`` the provider is auto-selected based on prompt
length and available keys.
"""
if config is not None:
resolved = config.model_copy()
if not resolved.api_key:
resolved.api_key = self._providers.get(resolved.provider, "")
if not resolved.model:
resolved.model = DEFAULT_MODELS.get(resolved.provider.value, "")
return resolved
provider = self._auto_select_provider(prompt)
return LLMConfig(
provider=provider,
model=DEFAULT_MODELS[provider.value],
api_key=self._providers.get(provider, ""),
)
def _auto_select_provider(self, prompt: str) -> LLMProvider:
"""Pick the best available provider for *prompt*."""
length = len(prompt)
if length < 5_000 and LLMProvider.GEMINI in self._providers:
return LLMProvider.GEMINI
if length > 10_000 and LLMProvider.CLAUDE in self._providers:
return LLMProvider.CLAUDE
if LLMProvider.OPENAI in self._providers:
return LLMProvider.OPENAI
# Fallback: use whatever is available
for p in (LLMProvider.GEMINI, LLMProvider.CLAUDE, LLMProvider.OPENAI):
if p in self._providers:
return p
raise RuntimeError("No LLM provider configured. Call configure() first.")
async def generate(
self,
prompt: str,
config: Optional[LLMConfig] = None,
system_prompt: str = "",
) -> str:
"""Generate a complete response from the best available LLM."""
cfg = self._resolve_config(prompt, config)
dispatch = {
LLMProvider.GEMINI: self._gemini_generate,
LLMProvider.CLAUDE: self._claude_generate,
LLMProvider.OPENAI: self._openai_generate,
}
handler = dispatch[cfg.provider]
return await handler(
prompt, system_prompt, cfg.model, cfg.api_key,
cfg.temperature, cfg.max_tokens,
)
async def stream(
self,
prompt: str,
config: Optional[LLMConfig] = None,
system_prompt: str = "",
) -> AsyncGenerator[str, None]:
"""Stream response chunks from the LLM."""
cfg = self._resolve_config(prompt, config)
dispatch = {
LLMProvider.GEMINI: self._gemini_stream,
LLMProvider.CLAUDE: self._claude_stream,
LLMProvider.OPENAI: self._openai_stream,
}
handler = dispatch[cfg.provider]
async for chunk in handler(
prompt, system_prompt, cfg.model, cfg.api_key,
cfg.temperature, cfg.max_tokens,
):
yield chunk
# -- Gemini -------------------------------------------------------------
async def _gemini_generate(
self, prompt: str, system: str, model: str,
api_key: str, temperature: float, max_tokens: int,
) -> str:
"""Call Google Gemini API (non-streaming)."""
try:
import google.generativeai as genai # lazy import
except ImportError as exc:
raise RuntimeError(
"google-generativeai is not installed. "
"Run: pip install google-generativeai"
) from exc
genai.configure(api_key=api_key)
gen_model = genai.GenerativeModel(
model_name=model,
system_instruction=system or None,
generation_config=genai.GenerationConfig(
temperature=temperature,
max_output_tokens=max_tokens,
),
)
response = await asyncio.to_thread(
gen_model.generate_content, prompt,
)
return response.text
async def _gemini_stream(
self, prompt: str, system: str, model: str,
api_key: str, temperature: float, max_tokens: int,
) -> AsyncGenerator[str, None]:
"""Call Google Gemini API (streaming)."""
try:
import google.generativeai as genai
except ImportError as exc:
raise RuntimeError(
"google-generativeai is not installed. "
"Run: pip install google-generativeai"
) from exc
genai.configure(api_key=api_key)
gen_model = genai.GenerativeModel(
model_name=model,
system_instruction=system or None,
generation_config=genai.GenerationConfig(
temperature=temperature,
max_output_tokens=max_tokens,
),
)
response = await asyncio.to_thread(
gen_model.generate_content, prompt, stream=True,
)
for chunk in response:
if chunk.text:
yield chunk.text
# -- Claude -------------------------------------------------------------
async def _claude_generate(
self, prompt: str, system: str, model: str,
api_key: str, temperature: float, max_tokens: int,
) -> str:
"""Call Anthropic Claude API (non-streaming)."""
try:
import anthropic # lazy import
except ImportError as exc:
raise RuntimeError(
"anthropic is not installed. Run: pip install anthropic"
) from exc
client = anthropic.AsyncAnthropic(api_key=api_key)
message = await client.messages.create(
model=model,
max_tokens=max_tokens,
temperature=temperature,
system=system or "You are a helpful financial analyst.",
messages=[{"role": "user", "content": prompt}],
)
return message.content[0].text
async def _claude_stream(
self, prompt: str, system: str, model: str,
api_key: str, temperature: float, max_tokens: int,
) -> AsyncGenerator[str, None]:
"""Call Anthropic Claude API (streaming)."""
try:
import anthropic
except ImportError as exc:
raise RuntimeError(
"anthropic is not installed. Run: pip install anthropic"
) from exc
client = anthropic.AsyncAnthropic(api_key=api_key)
async with client.messages.stream(
model=model,
max_tokens=max_tokens,
temperature=temperature,
system=system or "You are a helpful financial analyst.",
messages=[{"role": "user", "content": prompt}],
) as stream:
async for text in stream.text_stream:
yield text
# -- OpenAI -------------------------------------------------------------
async def _openai_generate(
self, prompt: str, system: str, model: str,
api_key: str, temperature: float, max_tokens: int,
) -> str:
"""Call OpenAI API (non-streaming)."""
try:
import openai # lazy import
except ImportError as exc:
raise RuntimeError(
"openai is not installed. Run: pip install openai"
) from exc
client = openai.AsyncOpenAI(api_key=api_key)
messages: list[dict[str, str]] = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
response = await client.chat.completions.create(
model=model,
messages=messages, # type: ignore[arg-type]
temperature=temperature,
max_tokens=max_tokens,
)
choice = response.choices[0]
return choice.message.content or ""
async def _openai_stream(
self, prompt: str, system: str, model: str,
api_key: str, temperature: float, max_tokens: int,
) -> AsyncGenerator[str, None]:
"""Call OpenAI API (streaming)."""
try:
import openai
except ImportError as exc:
raise RuntimeError(
"openai is not installed. Run: pip install openai"
) from exc
client = openai.AsyncOpenAI(api_key=api_key)
messages: list[dict[str, str]] = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
stream = await client.chat.completions.create(
model=model,
messages=messages, # type: ignore[arg-type]
temperature=temperature,
max_tokens=max_tokens,
stream=True,
)
async for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
yield delta.content
# ---------------------------------------------------------------------------
# Singleton
# ---------------------------------------------------------------------------
llm_router = LLMRouter()
+221
View File
@@ -0,0 +1,221 @@
"""Two-tier caching system for ATLAS Terminal.
Tier 1 ``MemoryCache``: fast in-process dict with per-key TTL.
Tier 2 ``DBCache``: durable SQLite-backed cache with per-key TTL.
``CacheManager`` orchestrates both tiers (memory-first, DB-second).
"""
import json
import time
from typing import Any, Dict, Optional
import aiosqlite
from server.db.database import get_db
# ---------------------------------------------------------------------------
# Tier 1: In-memory cache
# ---------------------------------------------------------------------------
_DEFAULT_MEMORY_TTL: int = 300 # 5 minutes
class MemoryCache:
"""Thread-*unsafe* in-memory cache backed by a plain dict.
Each entry stores ``(value, expiry_timestamp)``. Expired entries are
lazily evicted on ``get()``.
"""
def __init__(self) -> None:
self._store: Dict[str, tuple[Any, float]] = {}
def get(self, key: str) -> Optional[Any]:
"""Return the cached value for *key*, or ``None`` if missing/expired.
Args:
key: Cache key.
Returns:
The stored value, or ``None``.
"""
entry = self._store.get(key)
if entry is None:
return None
value, expires_at = entry
if time.time() > expires_at:
del self._store[key]
return None
return value
def set(self, key: str, value: Any, ttl: int = _DEFAULT_MEMORY_TTL) -> None:
"""Store *value* under *key* with the given TTL in seconds.
Args:
key: Cache key.
value: Arbitrary Python object to cache.
ttl: Time-to-live in seconds (default 300).
"""
self._store[key] = (value, time.time() + ttl)
def delete(self, key: str) -> None:
"""Remove *key* from the cache (no-op if absent).
Args:
key: Cache key.
"""
self._store.pop(key, None)
def clear(self) -> None:
"""Remove all entries from the cache."""
self._store.clear()
# ---------------------------------------------------------------------------
# Tier 2: SQLite-backed cache
# ---------------------------------------------------------------------------
_DEFAULT_DB_TTL: int = 86400 # 1 day
class DBCache:
"""Durable cache that persists entries in the ``cache`` SQLite table.
Values are stored as JSON-encoded text so that structured data survives
a round-trip.
"""
async def get(self, key: str) -> Optional[str]:
"""Return the cached value for *key*, or ``None`` if missing/expired.
Args:
key: Cache key.
Returns:
The stored value string, or ``None``.
"""
db: aiosqlite.Connection = await get_db()
cursor = await db.execute(
"SELECT value, expires_at FROM cache WHERE key = ?",
(key,),
)
row = await cursor.fetchone()
if row is None:
return None
value: str = row[0]
expires_at: float = row[1]
if time.time() > expires_at:
await db.execute("DELETE FROM cache WHERE key = ?", (key,))
await db.commit()
return None
return value
async def set(self, key: str, value: str, ttl: int = _DEFAULT_DB_TTL) -> None:
"""Store *value* under *key* with the given TTL in seconds.
Uses ``INSERT OR REPLACE`` so existing entries are overwritten.
Args:
key: Cache key.
value: String value to persist.
ttl: Time-to-live in seconds (default 86400).
"""
db: aiosqlite.Connection = await get_db()
expires_at = time.time() + ttl
await db.execute(
"INSERT OR REPLACE INTO cache (key, value, expires_at) VALUES (?, ?, ?)",
(key, value, expires_at),
)
await db.commit()
async def cleanup(self) -> None:
"""Delete all expired entries from the cache table."""
db: aiosqlite.Connection = await get_db()
await db.execute(
"DELETE FROM cache WHERE expires_at < ?",
(time.time(),),
)
await db.commit()
# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------
class CacheManager:
"""Two-tier cache that checks memory first, then SQLite.
Usage::
value = await cache_manager.get("key")
await cache_manager.set("key", payload, memory_ttl=60, db_ttl=3600)
"""
def __init__(self) -> None:
self.memory = MemoryCache()
self.db = DBCache()
async def get(self, key: str) -> Optional[Any]:
"""Look up *key* in memory, then in the DB cache.
If the value is found only in the DB tier it is promoted back into
memory with the default memory TTL.
Args:
key: Cache key.
Returns:
The cached value (deserialized from JSON when coming from DB),
or ``None``.
"""
# Tier 1
mem_value = self.memory.get(key)
if mem_value is not None:
return mem_value
# Tier 2
db_value = await self.db.get(key)
if db_value is not None:
try:
deserialized = json.loads(db_value)
except (json.JSONDecodeError, TypeError):
deserialized = db_value
# Promote to memory for faster subsequent access
self.memory.set(key, deserialized)
return deserialized
return None
async def set(
self,
key: str,
value: Any,
memory_ttl: int = _DEFAULT_MEMORY_TTL,
db_ttl: int = _DEFAULT_DB_TTL,
) -> None:
"""Write *value* to both cache tiers.
The value is JSON-serialized before writing to the DB tier.
Args:
key: Cache key.
value: Arbitrary Python object to cache.
memory_ttl: TTL for the in-memory tier (default 300s).
db_ttl: TTL for the SQLite tier (default 86400s).
"""
self.memory.set(key, value, ttl=memory_ttl)
serialized = json.dumps(value, default=str)
await self.db.set(key, serialized, ttl=db_ttl)
# Module-level singleton
cache_manager: CacheManager = CacheManager()
+159
View File
@@ -0,0 +1,159 @@
"""Dashboard layout persistence backed by SQLite.
All functions operate on the ``dashboards`` table and return plain dicts.
"""
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
import aiosqlite
from server.db.database import get_db
def _row_to_dict(row: aiosqlite.Row) -> Dict[str, Any]:
"""Convert an ``aiosqlite.Row`` to a plain dict.
Args:
row: A database row.
Returns:
A dict keyed by column name.
"""
return dict(row)
def _now_iso() -> str:
"""Return the current UTC time as an ISO-8601 string.
Returns:
e.g. ``'2026-03-20T12:34:56'``
"""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
async def get_all_dashboards() -> List[Dict[str, Any]]:
"""Return all saved dashboards ordered by id.
Returns:
A list of dashboard dicts.
"""
db: aiosqlite.Connection = await get_db()
cursor = await db.execute("SELECT * FROM dashboards ORDER BY id")
rows = await cursor.fetchall()
return [_row_to_dict(r) for r in rows]
async def get_dashboard(dashboard_id: int) -> Optional[Dict[str, Any]]:
"""Fetch a single dashboard by its primary key.
Args:
dashboard_id: The id of the dashboard.
Returns:
A dashboard dict, or ``None`` if not found.
"""
db: aiosqlite.Connection = await get_db()
cursor = await db.execute(
"SELECT * FROM dashboards WHERE id = ?", (dashboard_id,)
)
row = await cursor.fetchone()
return _row_to_dict(row) if row else None
async def save_dashboard(name: str, layout_json: str) -> Dict[str, Any]:
"""Create a new dashboard.
Args:
name: Human-readable dashboard name.
layout_json: JSON string describing the widget layout.
Returns:
The newly created dashboard dict.
"""
db: aiosqlite.Connection = await get_db()
now = _now_iso()
cursor = await db.execute(
"""
INSERT INTO dashboards (name, layout_json, created_at, updated_at)
VALUES (?, ?, ?, ?)
""",
(name, layout_json, now, now),
)
await db.commit()
new_id = cursor.lastrowid
result_cursor = await db.execute(
"SELECT * FROM dashboards WHERE id = ?", (new_id,)
)
row = await result_cursor.fetchone()
return _row_to_dict(row) # type: ignore[arg-type]
async def update_dashboard(
dashboard_id: int,
name: Optional[str] = None,
layout_json: Optional[str] = None,
) -> Dict[str, Any]:
"""Update an existing dashboard's name and/or layout.
At least one of *name* or *layout_json* must be provided.
``updated_at`` is set automatically.
Args:
dashboard_id: The id of the dashboard to update.
name: New dashboard name (or ``None`` to leave unchanged).
layout_json: New layout JSON (or ``None`` to leave unchanged).
Returns:
The updated dashboard dict.
Raises:
ValueError: If the dashboard does not exist or no fields given.
"""
fields: Dict[str, Any] = {}
if name is not None:
fields["name"] = name
if layout_json is not None:
fields["layout_json"] = layout_json
if not fields:
raise ValueError("At least one of 'name' or 'layout_json' must be provided")
fields["updated_at"] = _now_iso()
set_clause = ", ".join(f"{col} = ?" for col in fields)
values = list(fields.values()) + [dashboard_id]
db: aiosqlite.Connection = await get_db()
await db.execute(
f"UPDATE dashboards SET {set_clause} WHERE id = ?", # noqa: S608
values,
)
await db.commit()
cursor = await db.execute(
"SELECT * FROM dashboards WHERE id = ?", (dashboard_id,)
)
row = await cursor.fetchone()
if row is None:
raise ValueError(f"Dashboard with id={dashboard_id} not found")
return _row_to_dict(row)
async def delete_dashboard(dashboard_id: int) -> bool:
"""Delete a dashboard by id.
Args:
dashboard_id: The id to delete.
Returns:
``True`` if a row was deleted, ``False`` otherwise.
"""
db: aiosqlite.Connection = await get_db()
cursor = await db.execute(
"DELETE FROM dashboards WHERE id = ?", (dashboard_id,)
)
await db.commit()
return cursor.rowcount > 0
+122
View File
@@ -0,0 +1,122 @@
"""SQLite database manager for ATLAS Terminal.
Provides async database access via aiosqlite with a singleton connection
pattern. Replaces the previous Supabase dependency with local-first SQLite.
"""
import os
from pathlib import Path
from typing import Optional
import aiosqlite
# Resolve the database file path relative to this module
_DB_DIR: Path = Path(__file__).resolve().parent.parent / "data"
_DB_PATH: str = str(_DB_DIR / "atlas.db")
# Singleton connection holder
_connection: Optional[aiosqlite.Connection] = None
async def get_db() -> aiosqlite.Connection:
"""Return the singleton async SQLite connection.
Creates the connection (and the data/ directory) on first call.
Enables WAL mode and foreign keys for better concurrency and integrity.
Returns:
An open ``aiosqlite.Connection`` ready for queries.
"""
global _connection
if _connection is not None:
return _connection
_DB_DIR.mkdir(parents=True, exist_ok=True)
_connection = await aiosqlite.connect(_DB_PATH)
_connection.row_factory = aiosqlite.Row
await _connection.execute("PRAGMA journal_mode=WAL")
await _connection.execute("PRAGMA foreign_keys=ON")
return _connection
async def init_db() -> None:
"""Create all application tables if they do not already exist.
Should be called once during application startup (e.g. in a FastAPI
``lifespan`` handler).
"""
db = await get_db()
await db.execute(
"""
CREATE TABLE IF NOT EXISTS portfolio_positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticker TEXT NOT NULL,
name TEXT NOT NULL,
shares REAL NOT NULL,
avg_cost REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'USD',
broker TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
"""
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS watchlist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticker TEXT NOT NULL UNIQUE,
added_at TEXT NOT NULL DEFAULT (datetime('now'))
)
"""
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS dashboards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
layout_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
"""
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
"""
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
expires_at REAL NOT NULL
)
"""
)
await db.commit()
async def close_db() -> None:
"""Close the singleton database connection.
Safe to call even if the connection was never opened.
"""
global _connection
if _connection is not None:
await _connection.close()
_connection = None
+62
View File
@@ -0,0 +1,62 @@
"""
PostgreSQL cache repository — persistent cache with TTL.
"""
import json
from typing import Optional, Any
from datetime import datetime, timezone, timedelta
from server.db.pg_database import get_pg_pool
async def pg_cache_get(key: str) -> Optional[Any]:
"""Get a cached value. Returns None if expired or not found."""
pool = await get_pg_pool()
if not pool:
return None
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT value FROM cache WHERE key = $1 AND expires_at > NOW()",
key,
)
if row and row["value"] is not None:
return row["value"] # JSONB auto-deserializes
return None
async def pg_cache_set(key: str, value: Any, ttl_seconds: int = 86400) -> None:
"""Set a cache value with TTL."""
pool = await get_pg_pool()
if not pool:
return
expires = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
async with pool.acquire() as conn:
await conn.execute(
"""INSERT INTO cache (key, value, expires_at)
VALUES ($1, $2::jsonb, $3)
ON CONFLICT (key) DO UPDATE SET value = $2::jsonb, expires_at = $3""",
key, json.dumps(value), expires,
)
async def pg_cache_delete(key: str) -> None:
"""Delete a specific cache entry."""
pool = await get_pg_pool()
if not pool:
return
async with pool.acquire() as conn:
await conn.execute("DELETE FROM cache WHERE key = $1", key)
async def pg_cache_cleanup() -> int:
"""Remove expired cache entries. Returns count of deleted rows."""
pool = await get_pg_pool()
if not pool:
return 0
async with pool.acquire() as conn:
result = await conn.execute(
"DELETE FROM cache WHERE expires_at < NOW()"
)
# Parse "DELETE N" result
try:
return int(result.split()[-1])
except (IndexError, ValueError):
return 0
+115
View File
@@ -0,0 +1,115 @@
"""
PostgreSQL async connection manager for ATLAS Terminal.
Uses asyncpg for high-performance async PostgreSQL operations.
Configurable via DATABASE_URL environment variable.
"""
import os
import json
import logging
from typing import Optional, Any
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
# Try to import asyncpg
try:
import asyncpg
HAS_ASYNCPG = True
except ImportError:
HAS_ASYNCPG = False
asyncpg = None
_pool: Optional[Any] = None
async def get_pg_pool() -> Optional[Any]:
"""Get or create the PostgreSQL connection pool."""
global _pool
if not HAS_ASYNCPG:
logger.warning("asyncpg not installed. Run: pip install asyncpg")
return None
if _pool is not None:
return _pool
database_url = os.getenv("DATABASE_URL", "")
if not database_url:
logger.info("No DATABASE_URL set, PostgreSQL disabled.")
return None
try:
_pool = await asyncpg.create_pool(
database_url,
min_size=2,
max_size=10,
command_timeout=30,
)
logger.info("PostgreSQL connection pool created.")
return _pool
except Exception as e:
logger.error(f"Failed to create PostgreSQL pool: {e}")
return None
async def init_pg_tables() -> None:
"""Create tables if they don't exist in PostgreSQL."""
pool = await get_pg_pool()
if not pool:
return
async with pool.acquire() as conn:
await conn.execute("""
CREATE TABLE IF NOT EXISTS portfolio_positions (
id SERIAL PRIMARY KEY,
ticker VARCHAR(20) NOT NULL,
name VARCHAR(200),
shares DOUBLE PRECISION NOT NULL DEFAULT 0,
avg_cost DOUBLE PRECISION NOT NULL DEFAULT 0,
currency VARCHAR(10) DEFAULT 'USD',
broker VARCHAR(100),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
""")
await conn.execute("""
CREATE TABLE IF NOT EXISTS watchlist (
id SERIAL PRIMARY KEY,
ticker VARCHAR(20) NOT NULL UNIQUE,
added_at TIMESTAMPTZ DEFAULT NOW()
);
""")
await conn.execute("""
CREATE TABLE IF NOT EXISTS dashboards (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
layout_json JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
""")
await conn.execute("""
CREATE TABLE IF NOT EXISTS settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT
);
""")
await conn.execute("""
CREATE TABLE IF NOT EXISTS cache (
key VARCHAR(500) PRIMARY KEY,
value JSONB,
expires_at TIMESTAMPTZ
);
""")
# Index for cache expiry cleanup
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
""")
logger.info("PostgreSQL tables initialized.")
async def close_pg_pool() -> None:
"""Close the PostgreSQL connection pool."""
global _pool
if _pool:
await _pool.close()
_pool = None
logger.info("PostgreSQL pool closed.")
@@ -0,0 +1,110 @@
"""
PostgreSQL portfolio repository — full CRUD for portfolio positions.
"""
from typing import Optional
from datetime import datetime, timezone
from server.db.pg_database import get_pg_pool
async def pg_get_all_positions() -> list[dict]:
"""Get all portfolio positions from PostgreSQL."""
pool = await get_pg_pool()
if not pool:
return []
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT * FROM portfolio_positions ORDER BY updated_at DESC"
)
return [dict(r) for r in rows]
async def pg_add_position(
ticker: str,
name: str = "",
shares: float = 0.0,
avg_cost: float = 0.0,
currency: str = "USD",
broker: str = "",
) -> Optional[dict]:
"""Add a new position to PostgreSQL."""
pool = await get_pg_pool()
if not pool:
return None
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""INSERT INTO portfolio_positions (ticker, name, shares, avg_cost, currency, broker)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *""",
ticker.upper(), name, shares, avg_cost, currency, broker,
)
return dict(row) if row else None
async def pg_update_position(position_id: int, **fields) -> Optional[dict]:
"""Update a position by ID."""
pool = await get_pg_pool()
if not pool:
return None
allowed = {"ticker", "name", "shares", "avg_cost", "currency", "broker"}
updates = {k: v for k, v in fields.items() if k in allowed}
if not updates:
return None
updates["updated_at"] = datetime.now(timezone.utc)
set_clauses = ", ".join(f"{k} = ${i+2}" for i, k in enumerate(updates.keys()))
values = [position_id] + list(updates.values())
async with pool.acquire() as conn:
row = await conn.fetchrow(
f"UPDATE portfolio_positions SET {set_clauses} WHERE id = $1 RETURNING *",
*values,
)
return dict(row) if row else None
async def pg_delete_position(position_id: int) -> bool:
"""Delete a position by ID."""
pool = await get_pg_pool()
if not pool:
return False
async with pool.acquire() as conn:
result = await conn.execute(
"DELETE FROM portfolio_positions WHERE id = $1", position_id
)
return result == "DELETE 1"
async def pg_get_position_by_ticker(ticker: str) -> Optional[dict]:
"""Get a position by ticker symbol."""
pool = await get_pg_pool()
if not pool:
return None
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM portfolio_positions WHERE ticker = $1", ticker.upper()
)
return dict(row) if row else None
async def pg_bulk_add_positions(positions: list[dict]) -> list[dict]:
"""Bulk add positions (from OCR screenshot)."""
pool = await get_pg_pool()
if not pool:
return []
results = []
async with pool.acquire() as conn:
async with conn.transaction():
for p in positions:
row = await conn.fetchrow(
"""INSERT INTO portfolio_positions (ticker, name, shares, avg_cost, currency, broker)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *""",
p.get("ticker", "").upper(),
p.get("name", ""),
float(p.get("shares", 0)),
float(p.get("avg_cost", 0)),
p.get("currency", "USD"),
p.get("broker", ""),
)
if row:
results.append(dict(row))
return results
+222
View File
@@ -0,0 +1,222 @@
"""Portfolio CRUD operations backed by SQLite.
All functions operate on the ``portfolio_positions`` table and return plain
dicts so they can be serialized directly by FastAPI.
"""
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
import aiosqlite
from server.db.database import get_db
def _row_to_dict(row: aiosqlite.Row) -> Dict[str, Any]:
"""Convert an ``aiosqlite.Row`` to a plain dict.
Args:
row: A database row returned with ``row_factory = aiosqlite.Row``.
Returns:
A dict keyed by column name.
"""
return dict(row)
def _now_iso() -> str:
"""Return the current UTC time as an ISO-8601 string.
Returns:
e.g. ``'2026-03-20T12:34:56'``
"""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
async def get_all_positions() -> List[Dict[str, Any]]:
"""Return every row in ``portfolio_positions`` ordered by id.
Returns:
A list of position dicts.
"""
db: aiosqlite.Connection = await get_db()
cursor = await db.execute(
"SELECT * FROM portfolio_positions ORDER BY id"
)
rows = await cursor.fetchall()
return [_row_to_dict(r) for r in rows]
async def add_position(
ticker: str,
name: str,
shares: float,
avg_cost: float,
currency: str = "USD",
broker: Optional[str] = None,
) -> Dict[str, Any]:
"""Insert a new portfolio position.
Args:
ticker: Stock ticker symbol (e.g. ``'AAPL'``).
name: Human-readable security name.
shares: Number of shares held.
avg_cost: Average cost basis per share.
currency: ISO currency code (default ``'USD'``).
broker: Optional broker name.
Returns:
The newly created position as a dict (including generated id).
"""
db: aiosqlite.Connection = await get_db()
now = _now_iso()
cursor = await db.execute(
"""
INSERT INTO portfolio_positions
(ticker, name, shares, avg_cost, currency, broker, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(ticker, name, shares, avg_cost, currency, broker, now, now),
)
await db.commit()
new_id = cursor.lastrowid
result_cursor = await db.execute(
"SELECT * FROM portfolio_positions WHERE id = ?", (new_id,)
)
row = await result_cursor.fetchone()
return _row_to_dict(row) # type: ignore[arg-type]
async def update_position(position_id: int, **kwargs: Any) -> Dict[str, Any]:
"""Update an existing portfolio position.
Only the supplied keyword arguments are modified; all others remain
unchanged. ``updated_at`` is set automatically.
Args:
position_id: The primary-key id of the position to update.
**kwargs: Column names and their new values.
Returns:
The updated position dict.
Raises:
ValueError: If *position_id* does not exist or no fields are given.
"""
if not kwargs:
raise ValueError("No fields provided for update")
allowed_fields = {"ticker", "name", "shares", "avg_cost", "currency", "broker"}
fields = {k: v for k, v in kwargs.items() if k in allowed_fields}
if not fields:
raise ValueError(
f"No valid fields to update. Allowed: {allowed_fields}"
)
fields["updated_at"] = _now_iso()
set_clause = ", ".join(f"{col} = ?" for col in fields)
values = list(fields.values()) + [position_id]
db: aiosqlite.Connection = await get_db()
await db.execute(
f"UPDATE portfolio_positions SET {set_clause} WHERE id = ?", # noqa: S608
values,
)
await db.commit()
cursor = await db.execute(
"SELECT * FROM portfolio_positions WHERE id = ?", (position_id,)
)
row = await cursor.fetchone()
if row is None:
raise ValueError(f"Position with id={position_id} not found")
return _row_to_dict(row)
async def delete_position(position_id: int) -> bool:
"""Delete a portfolio position by id.
Args:
position_id: The primary-key id to delete.
Returns:
``True`` if a row was deleted, ``False`` if no matching row existed.
"""
db: aiosqlite.Connection = await get_db()
cursor = await db.execute(
"DELETE FROM portfolio_positions WHERE id = ?", (position_id,)
)
await db.commit()
return cursor.rowcount > 0
async def get_position_by_ticker(ticker: str) -> Optional[Dict[str, Any]]:
"""Look up a position by ticker symbol.
If multiple positions share the same ticker (e.g. different brokers),
the first one (lowest id) is returned.
Args:
ticker: The ticker to search for (case-sensitive).
Returns:
A position dict, or ``None`` if not found.
"""
db: aiosqlite.Connection = await get_db()
cursor = await db.execute(
"SELECT * FROM portfolio_positions WHERE ticker = ? ORDER BY id LIMIT 1",
(ticker,),
)
row = await cursor.fetchone()
return _row_to_dict(row) if row else None
async def bulk_add_positions(
positions: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Insert multiple positions in a single transaction.
Each dict in *positions* must contain at least ``ticker``, ``name``,
``shares``, and ``avg_cost``. Optional keys: ``currency``, ``broker``.
Args:
positions: A list of position dicts.
Returns:
A list of the newly created position dicts.
"""
db: aiosqlite.Connection = await get_db()
now = _now_iso()
created: List[Dict[str, Any]] = []
for pos in positions:
cursor = await db.execute(
"""
INSERT INTO portfolio_positions
(ticker, name, shares, avg_cost, currency, broker, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
pos["ticker"],
pos["name"],
pos["shares"],
pos["avg_cost"],
pos.get("currency", "USD"),
pos.get("broker"),
now,
now,
),
)
new_id = cursor.lastrowid
result_cursor = await db.execute(
"SELECT * FROM portfolio_positions WHERE id = ?", (new_id,)
)
row = await result_cursor.fetchone()
created.append(_row_to_dict(row)) # type: ignore[arg-type]
await db.commit()
return created
+68
View File
@@ -0,0 +1,68 @@
"""User settings persistence backed by SQLite.
Provides a simple key-value store for application settings such as API keys
and user preferences, using the ``settings`` table.
"""
from typing import Dict, Optional
import aiosqlite
from server.db.database import get_db
async def get_setting(key: str) -> Optional[str]:
"""Retrieve a single setting by key.
Args:
key: The setting key to look up.
Returns:
The setting value, or ``None`` if the key does not exist.
"""
db: aiosqlite.Connection = await get_db()
cursor = await db.execute(
"SELECT value FROM settings WHERE key = ?", (key,)
)
row = await cursor.fetchone()
return row[0] if row else None
async def set_setting(key: str, value: str) -> None:
"""Create or update a setting.
Uses ``INSERT OR REPLACE`` so the call is idempotent.
Args:
key: The setting key.
value: The setting value.
"""
db: aiosqlite.Connection = await get_db()
await db.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
(key, value),
)
await db.commit()
async def get_all_settings() -> Dict[str, str]:
"""Return every setting as a ``{key: value}`` dict.
Returns:
A dict mapping all stored keys to their values.
"""
db: aiosqlite.Connection = await get_db()
cursor = await db.execute("SELECT key, value FROM settings ORDER BY key")
rows = await cursor.fetchall()
return {row[0]: row[1] for row in rows}
async def delete_setting(key: str) -> None:
"""Remove a setting by key (no-op if the key does not exist).
Args:
key: The setting key to delete.
"""
db: aiosqlite.Connection = await get_db()
await db.execute("DELETE FROM settings WHERE key = ?", (key,))
await db.commit()
+95
View File
@@ -0,0 +1,95 @@
"""
Unified repository — routes operations to PostgreSQL or SQLite
based on DATABASE_URL environment variable.
Usage:
from server.db.unified_repo import repo
positions = await repo.get_all_positions()
"""
import os
import logging
from typing import Optional, Any
logger = logging.getLogger(__name__)
def _use_postgres() -> bool:
"""Check if PostgreSQL should be used."""
return bool(os.getenv("DATABASE_URL", ""))
class UnifiedRepo:
"""Routes database operations to the appropriate backend."""
async def get_all_positions(self) -> list[dict]:
if _use_postgres():
from server.db.pg_portfolio_repo import pg_get_all_positions
return await pg_get_all_positions()
from server.db.portfolio_repo import get_all_positions
return await get_all_positions()
async def add_position(self, **kwargs) -> Optional[dict]:
if _use_postgres():
from server.db.pg_portfolio_repo import pg_add_position
return await pg_add_position(**kwargs)
from server.db.portfolio_repo import add_position
return await add_position(**kwargs)
async def update_position(self, position_id: int, **kwargs) -> Optional[dict]:
if _use_postgres():
from server.db.pg_portfolio_repo import pg_update_position
return await pg_update_position(position_id, **kwargs)
from server.db.portfolio_repo import update_position
return await update_position(position_id, **kwargs)
async def delete_position(self, position_id: int) -> bool:
if _use_postgres():
from server.db.pg_portfolio_repo import pg_delete_position
return await pg_delete_position(position_id)
from server.db.portfolio_repo import delete_position
return await delete_position(position_id)
async def bulk_add_positions(self, positions: list[dict]) -> list[dict]:
if _use_postgres():
from server.db.pg_portfolio_repo import pg_bulk_add_positions
return await pg_bulk_add_positions(positions)
from server.db.portfolio_repo import bulk_add_positions
return await bulk_add_positions(positions)
async def cache_get(self, key: str) -> Optional[Any]:
if _use_postgres():
from server.db.pg_cache_repo import pg_cache_get
return await pg_cache_get(key)
from server.db.cache import cache_manager
return await cache_manager.get(key)
async def cache_set(self, key: str, value: Any, ttl: int = 86400) -> None:
if _use_postgres():
from server.db.pg_cache_repo import pg_cache_set
return await pg_cache_set(key, value, ttl)
from server.db.cache import cache_manager
await cache_manager.set(key, value, ttl)
async def init_db(self) -> None:
"""Initialize the appropriate database."""
if _use_postgres():
from server.db.pg_database import init_pg_tables
await init_pg_tables()
logger.info("Using PostgreSQL backend.")
else:
from server.db.database import init_db
await init_db()
logger.info("Using SQLite backend.")
async def close_db(self) -> None:
"""Close database connections."""
if _use_postgres():
from server.db.pg_database import close_pg_pool
await close_pg_pool()
else:
from server.db.database import close_db
await close_db()
# Singleton
repo = UnifiedRepo()
+75
View File
@@ -0,0 +1,75 @@
"""
ATLAS Terminal — FastAPI Backend
Unified entry point with PostgreSQL + SQLite support.
"""
import os
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialize database on startup, close on shutdown."""
from server.db.unified_repo import repo
await repo.init_db()
logger.info("ATLAS Terminal backend started.")
yield
await repo.close_db()
logger.info("ATLAS Terminal backend stopped.")
app = FastAPI(
title="ATLAS Terminal API",
description="Personal Bloomberg Terminal — Hybrid AI + Quantitative Analysis",
version="2.0.0",
lifespan=lifespan,
)
# CORS — allow local frontend
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:3000",
"http://localhost:3001",
"http://127.0.0.1:3000",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Mount routers ---
from server.routers import edgar, analysis, valuation, market_data, news, crypto, fx, portfolio, technical, financials, estimates, earnings, insider # noqa: E402
app.include_router(edgar.router, prefix="/api/edgar", tags=["SEC EDGAR"])
app.include_router(analysis.router, prefix="/api/analysis", tags=["AI Analysis"])
app.include_router(valuation.router, prefix="/api/valuation", tags=["Valuation"])
app.include_router(market_data.router, prefix="/api/market", tags=["Market Data"])
app.include_router(financials.router, prefix="/api/financials", tags=["Financials"])
app.include_router(estimates.router, prefix="/api/estimates", tags=["Estimates"])
app.include_router(news.router, prefix="/api/news", tags=["News"])
app.include_router(crypto.router, prefix="/api/crypto", tags=["Crypto"])
app.include_router(fx.router, prefix="/api/fx", tags=["FX"])
app.include_router(portfolio.router, prefix="/api/portfolio", tags=["Portfolio"])
app.include_router(technical.router, prefix="/api/technical", tags=["Technical"])
app.include_router(earnings.router, prefix="/api/earnings", tags=["Earnings"])
app.include_router(insider.router, prefix="/api/insider", tags=["Insider Trading"])
@app.get("/health")
async def health_check():
"""Health check endpoint."""
db_type = "postgresql" if os.getenv("DATABASE_URL") else "sqlite"
return {"status": "ok", "db": db_type, "version": "2.0.0"}
@app.get("/api/health")
async def api_health_check():
"""Health check endpoint (via /api prefix for Next.js proxy)."""
db_type = "postgresql" if os.getenv("DATABASE_URL") else "sqlite"
return {"status": "ok", "db": db_type, "version": "2.0.0"}
+22
View File
@@ -0,0 +1,22 @@
"""Supabase database client for ATLAS Terminal."""
import os
from typing import Optional
# Supabase client (lazy init)
_supabase_client = None
def get_supabase():
"""Get or create Supabase client. Returns None if not configured."""
global _supabase_client
if _supabase_client is not None:
return _supabase_client
url = os.environ.get("SUPABASE_URL")
key = os.environ.get("SUPABASE_KEY")
if not url or not key:
return None
try:
from supabase import create_client
_supabase_client = create_client(url, key)
return _supabase_client
except ImportError:
return None
+257
View File
@@ -0,0 +1,257 @@
"""Pydantic request/response schemas for ATLAS Terminal API."""
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
# ---------------------------------------------------------------------------
# Request models
# ---------------------------------------------------------------------------
class TickerRequest(BaseModel):
"""Generic request carrying a ticker and optional market selector."""
ticker: str = Field(..., description="Stock ticker symbol, e.g. AAPL, 005930.KS")
market: str = Field(
default="US (S&P/Dow/Nasdaq)",
description="Market selector: US, South Korea (KOSPI/KOSDAQ), Japan (Nikkei), UK (LSE)",
)
class EdgarRequest(BaseModel):
"""Request to download / fetch SEC EDGAR 10-K filings."""
ticker: str = Field(..., description="Stock ticker symbol")
email: str = Field(..., description="Email address required by SEC EDGAR fair-access policy")
class AnalysisRequest(BaseModel):
"""Request for AI-powered 10-K analysis (Gemini)."""
ticker: str
api_key: str = Field(..., description="Google Gemini API key")
sector: str = ""
industry: str = ""
class DCFInputs(BaseModel):
"""Inputs for discounted cash-flow valuation."""
fcf: float = Field(..., description="Base free cash flow (trailing)")
wacc: float = Field(..., description="Weighted-average cost of capital (decimal, e.g. 0.10)")
terminal_growth: float = Field(..., description="Terminal growth rate (decimal, e.g. 0.025)")
fcf_growth: float = Field(..., description="Near-term FCF growth rate (decimal, e.g. 0.12)")
total_debt: float = Field(default=0, description="Total debt for bridge to equity value")
cash: float = Field(default=0, description="Cash & equivalents for bridge to equity value")
shares: float = Field(default=1, description="Shares outstanding for per-share value")
class CompanySearch(BaseModel):
"""Search for a company by name or partial ticker."""
query: str = Field(..., description="Search term, e.g. 'Apple', 'Samsung'")
market: str = ""
class CompsRequest(BaseModel):
"""Request for industry comparable companies data."""
tickers: List[str] = Field(..., description="List of ticker symbols to compare")
class ForensicRequest(BaseModel):
"""Request for forensic audit analysis (Item 3 & 9A)."""
ticker: str
api_key: str
item3: str = ""
item9a: str = ""
class FinancialsLLMRequest(BaseModel):
"""Request to extract financials from Item 8 via LLM."""
ticker: str
api_key: str
item8_text: str = ""
class PortfolioPositionCreate(BaseModel):
"""Create a new portfolio position."""
ticker: str
company_name: str = ""
quantity: float
avg_price: float
currency: str = "USD"
source: str = "manual"
# ---------------------------------------------------------------------------
# Response models
# ---------------------------------------------------------------------------
class DCFResult(BaseModel):
"""Result of a DCF valuation calculation."""
enterprise_value: float = 0
equity_value: float = 0
value_per_share: Optional[float] = None
shares: Optional[float] = None
scenarios: Dict[str, Any] = {}
class DCFInputsResponse(BaseModel):
"""Auto-filled DCF inputs from market data."""
fcf: Optional[float] = None
total_debt: float = 0
cash: float = 0
shares: Optional[float] = None
class SmartDefaultsResponse(BaseModel):
"""Smart defaults for DCF with analyst consensus guidance."""
wacc: float = 0.10
terminal_growth: float = 0.025
fcf_growth: float = 0.10
sector: str = "N/A"
industry: str = "N/A"
class ConsensusResponse(BaseModel):
"""Analyst consensus data for a ticker."""
target_mean: Optional[float] = None
target_median: Optional[float] = None
target_low: Optional[float] = None
target_high: Optional[float] = None
recommendation: str = ""
num_analysts: int = 0
data: Dict[str, Any] = {}
class SectorIndustryResponse(BaseModel):
"""Sector and industry classification."""
sector: str = "N/A"
industry: str = "N/A"
class FinancialHealth(BaseModel):
"""Comprehensive financial health metrics."""
dupont: Dict[str, Any] = {}
altman_z: Dict[str, Any] = {}
red_flags: List[str] = []
piotroski: Dict[str, Any] = {}
class PiotroskiResponse(BaseModel):
"""Piotroski F-Score breakdown."""
score: int = 0
criteria: List[Dict[str, Any]] = []
used_ttm: bool = False
class SankeyData(BaseModel):
"""Income statement Sankey diagram data."""
labels: List[str] = []
sources: List[int] = []
targets: List[int] = []
values: List[float] = []
colors: List[str] = []
class RadarMetrics(BaseModel):
"""Normalised radar chart metrics."""
labels: List[str] = []
values: List[float] = []
raw: Dict[str, Any] = {}
class TrendData(BaseModel):
"""5-year financial trend data."""
years: List[int] = []
revenue: List[Optional[float]] = []
net_income: List[Optional[float]] = []
operating_margin: List[Optional[float]] = []
fcf: List[Optional[float]] = []
class NewsItem(BaseModel):
"""A single news article."""
title: str
source: str = ""
url: str = ""
published_at: str = ""
summary: str = ""
class PortfolioPosition(BaseModel):
"""A portfolio position with current market data."""
id: Optional[str] = None
ticker: str
company_name: str = ""
quantity: float
avg_price: float
currency: str = "USD"
source: str = "manual"
current_price: Optional[float] = None
market_value: Optional[float] = None
pnl: Optional[float] = None
pnl_pct: Optional[float] = None
class PortfolioSummary(BaseModel):
"""Aggregated portfolio summary."""
total_value: float = 0
total_cost: float = 0
total_pnl: float = 0
total_pnl_pct: Optional[float] = None
positions: List[PortfolioPosition] = []
class MarketOverview(BaseModel):
"""Market overview with indices, FX, and crypto."""
indices: Dict[str, Any] = {}
fx_rates: Dict[str, float] = {}
crypto: List[Dict[str, Any]] = []
class FXRateResponse(BaseModel):
"""Foreign exchange rate response."""
pair: str
rate: Optional[float] = None
rates: Dict[str, float] = {}
class FXHistoryResponse(BaseModel):
"""FX pair historical data."""
pair: str
dates: List[str] = []
rates: List[float] = []
class CryptoPrice(BaseModel):
"""Single cryptocurrency price data."""
symbol: str
name: str = ""
price_usd: Optional[float] = None
price_krw: Optional[float] = None
change_24h_pct: Optional[float] = None
class EdgarSectionsResponse(BaseModel):
"""Cached or downloaded 10-K section texts."""
status: str = ""
item1a: str = ""
item3: str = ""
item7: str = ""
item8: str = ""
item9a: str = ""
class Item7Response(BaseModel):
"""Item 7 MD&A text."""
item7: str = ""
class CompareResponse(BaseModel):
"""Comparison of latest vs 3-year-ago Item 7."""
item1a_latest: str = ""
item7_latest: str = ""
item7_3y_ago: Optional[str] = None
has_comparison: bool = False
class HealthCheckResponse(BaseModel):
"""API health check."""
status: str = "ok"
version: str = "1.0.0"
+202
View File
@@ -0,0 +1,202 @@
"""AI Analysis router -- Gemini-powered financial analysis.
Direct Gemini API calls without depending on Streamlit app module.
"""
import json
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
router = APIRouter()
class AnalysisRequest(BaseModel):
ticker: str
question: str = ""
api_key: str = ""
sector: str = ""
industry: str = ""
class SimpleQuestionRequest(BaseModel):
ticker: str
question: str
api_key: str = ""
def _call_gemini(api_key: str, prompt: str, max_tokens: int = 4096) -> str:
"""Call Gemini API directly and return text response."""
import urllib.request
import urllib.error
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={api_key}"
payload = json.dumps({
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {"maxOutputTokens": max_tokens, "temperature": 0.7}
}).encode("utf-8")
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read().decode("utf-8"))
candidates = data.get("candidates", [])
if candidates:
parts = candidates[0].get("content", {}).get("parts", [])
if parts:
return parts[0].get("text", "")
return "No response from Gemini."
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
logger.error("Gemini API error %d: %s", e.code, body)
raise HTTPException(status_code=e.code, detail=f"Gemini API error: {body[:200]}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Gemini call failed: {e}")
def _get_financial_context(ticker: str) -> str:
"""Build financial context from yfinance for AI analysis."""
try:
import yfinance as yf
t = yf.Ticker(ticker)
info = t.info or {}
ctx = f"""Company: {info.get('longName', ticker)} ({ticker})
Sector: {info.get('sector', 'N/A')} | Industry: {info.get('industry', 'N/A')}
Market Cap: ${info.get('marketCap', 0)/1e9:.1f}B
Revenue: ${info.get('totalRevenue', 0)/1e9:.1f}B | Revenue Growth: {(info.get('revenueGrowth', 0) or 0)*100:.1f}%
Profit Margin: {(info.get('profitMargins', 0) or 0)*100:.1f}% | Gross Margin: {(info.get('grossMargins', 0) or 0)*100:.1f}%
ROE: {(info.get('returnOnEquity', 0) or 0)*100:.1f}% | ROA: {(info.get('returnOnAssets', 0) or 0)*100:.1f}%
D/E: {info.get('debtToEquity', 'N/A')} | Current Ratio: {info.get('currentRatio', 'N/A')}
P/E: {info.get('trailingPE', 'N/A')} | Forward P/E: {info.get('forwardPE', 'N/A')}
Price: ${info.get('currentPrice', 'N/A')} | 52W High: ${info.get('fiftyTwoWeekHigh', 'N/A')} | 52W Low: ${info.get('fiftyTwoWeekLow', 'N/A')}
Target Mean: ${info.get('targetMeanPrice', 'N/A')} | Recommendation: {info.get('recommendationKey', 'N/A')}
Free Cash Flow: ${info.get('freeCashflow', 0)/1e9:.1f}B
"""
return ctx
except Exception:
return f"Ticker: {ticker}"
@router.post("/strategy", summary="AI financial analysis")
async def strategy_analysis(req: AnalysisRequest):
"""General AI financial analysis using Gemini."""
api_key = req.api_key
if not api_key:
raise HTTPException(status_code=400, detail="API key required. Set your Gemini key in Settings.")
context = _get_financial_context(req.ticker.upper())
question = req.question or f"Provide a comprehensive financial analysis of {req.ticker.upper()}"
prompt = f"""You are an expert financial analyst. Analyze the following company and answer the user's question.
{context}
User Question: {question}
Provide a detailed, professional analysis in markdown format. Include:
- Key financial metrics assessment
- Strengths and weaknesses
- Valuation perspective
- Risk factors
- Your overall assessment
Be specific with numbers and data. Answer in the same language as the question."""
result = _call_gemini(api_key, prompt)
return {"ticker": req.ticker.upper(), "analysis": result}
@router.post("/risks", summary="Risk analysis")
async def risk_analysis(req: AnalysisRequest):
"""AI-powered risk analysis."""
api_key = req.api_key
if not api_key:
raise HTTPException(status_code=400, detail="API key required.")
context = _get_financial_context(req.ticker.upper())
prompt = f"""You are a risk analyst. Analyze the following company's risk factors:
{context}
Provide a detailed risk assessment including:
1. Financial risks (leverage, liquidity, profitability trends)
2. Market risks (valuation, competition, sector headwinds)
3. Operational risks
4. Regulatory risks
5. Overall risk rating (Low/Medium/High)
Be specific and use the financial data provided. Answer in markdown format."""
result = _call_gemini(api_key, prompt)
return {"ticker": req.ticker.upper(), "analysis": result}
@router.post("/mda", summary="MD&A analysis")
async def mda_insights(req: AnalysisRequest):
"""AI management discussion analysis."""
api_key = req.api_key
if not api_key:
raise HTTPException(status_code=400, detail="API key required.")
context = _get_financial_context(req.ticker.upper())
prompt = f"""Analyze the management perspective for this company:
{context}
Provide insights on:
1. Revenue drivers and growth strategy
2. Margin trends and cost management
3. Capital allocation priorities
4. Key management concerns
5. Future outlook
Use markdown format with headers and bullet points."""
result = _call_gemini(api_key, prompt)
return {"ticker": req.ticker.upper(), "report": result}
@router.post("/forensic", summary="Forensic audit")
async def forensic_audit(req: AnalysisRequest):
"""AI forensic audit analysis."""
api_key = req.api_key
if not api_key:
raise HTTPException(status_code=400, detail="API key required.")
context = _get_financial_context(req.ticker.upper())
prompt = f"""Perform a forensic financial audit on this company:
{context}
Check for:
1. Earnings quality (cash flow vs net income)
2. Aggressive accounting signs
3. Related party transactions
4. Off-balance sheet items
5. Revenue recognition concerns
6. Management compensation alignment
Use markdown format. Be thorough but fair."""
result = _call_gemini(api_key, prompt)
return {"ticker": req.ticker.upper(), "forensic": result}
@router.post("/financials", summary="Extract financials via LLM")
async def extract_financials(req: AnalysisRequest):
"""Use Gemini to provide financial analysis."""
api_key = req.api_key
if not api_key:
raise HTTPException(status_code=400, detail="API key required.")
context = _get_financial_context(req.ticker.upper())
result = _call_gemini(api_key, f"Summarize the key financial data for analysis:\n\n{context}")
return {"ticker": req.ticker.upper(), "financials": result}
+256
View File
@@ -0,0 +1,256 @@
"""FastAPI router for AI chat with SSE streaming.
Compatible with Vercel AI SDK's ``useChat`` hook on the frontend.
SSE format: ``data: <text>\\n\\n`` per chunk, ``data: [DONE]\\n\\n`` at end.
"""
from __future__ import annotations
import json
import logging
import traceback
from typing import Any, AsyncGenerator, Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from server.ai.context_builder import context_builder
from server.ai.llm_router import LLMConfig, LLMProvider, llm_router
logger = logging.getLogger(__name__)
router = APIRouter(tags=["chat"])
# ---------------------------------------------------------------------------
# Request / Response schemas
# ---------------------------------------------------------------------------
class ChatMessage(BaseModel):
"""A single chat message."""
role: str = Field(..., description="Message role: 'user' or 'assistant'")
content: str = Field(..., description="Message text content")
class ChatRequest(BaseModel):
"""Payload for chat endpoints."""
messages: list[ChatMessage]
ticker: Optional[str] = None
active_widgets: list[str] = Field(default_factory=list)
widget_data: dict[str, Any] = Field(default_factory=dict)
provider: Optional[str] = None # Force a specific provider
class ConfigureRequest(BaseModel):
"""Payload for LLM configuration."""
provider: str
api_key: str
class ChatCompletionResponse(BaseModel):
"""Non-streaming chat response."""
content: str
provider: str
model: str
class SuggestedQuestionsResponse(BaseModel):
"""Suggested questions response."""
questions: list[str]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _build_prompt(messages: list[ChatMessage]) -> str:
"""Collapse chat history into a single prompt string.
The most recent user message is used as the primary prompt; earlier
messages provide conversational context.
"""
parts: list[str] = []
for msg in messages[:-1]:
prefix = "User" if msg.role == "user" else "Assistant"
parts.append(f"{prefix}: {msg.content}")
if messages:
parts.append(messages[-1].content)
return "\n\n".join(parts)
def _resolve_llm_config(
provider_name: Optional[str],
) -> Optional[LLMConfig]:
"""Build an ``LLMConfig`` if the caller forced a provider."""
if not provider_name:
return None
try:
provider = LLMProvider(provider_name.lower())
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Unknown provider '{provider_name}'. "
f"Supported: gemini, claude, openai",
)
return LLMConfig(provider=provider)
async def _sse_generator(
prompt: str,
system_prompt: str,
config: Optional[LLMConfig],
) -> AsyncGenerator[str, None]:
"""Yield SSE-formatted chunks compatible with Vercel AI SDK ``useChat``.
Format per chunk::
data: {"content":"<text>"}\n\n
Terminal event::
data: [DONE]\n\n
"""
try:
async for chunk in llm_router.stream(
prompt=prompt,
config=config,
system_prompt=system_prompt,
):
# Vercel AI SDK expects plain text chunks in `data:` field
yield f"data: {json.dumps({'content': chunk})}\n\n"
except RuntimeError as exc:
logger.error("LLM stream error: %s", exc)
yield f"data: {json.dumps({'error': str(exc)})}\n\n"
except Exception:
logger.error("Unexpected stream error:\n%s", traceback.format_exc())
yield f"data: {json.dumps({'error': 'Internal server error'})}\n\n"
finally:
yield "data: [DONE]\n\n"
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post("/stream")
async def chat_stream(request: ChatRequest) -> StreamingResponse:
"""Stream AI response via Server-Sent Events.
Compatible with Vercel AI SDK's ``useChat`` hook.
"""
if not request.messages:
raise HTTPException(status_code=400, detail="messages list is empty")
# 1. Build context from active widgets
system_prompt = context_builder.build_system_prompt(
ticker=request.ticker or "",
active_widgets=request.active_widgets,
widget_data=request.widget_data,
)
# 2. Build the prompt from conversation history
prompt = _build_prompt(request.messages)
# 3. Resolve optional provider override
config = _resolve_llm_config(request.provider)
# 4. Return SSE stream
return StreamingResponse(
_sse_generator(prompt, system_prompt, config),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.post("/complete", response_model=ChatCompletionResponse)
async def chat_complete(request: ChatRequest) -> ChatCompletionResponse:
"""Non-streaming AI response."""
if not request.messages:
raise HTTPException(status_code=400, detail="messages list is empty")
system_prompt = context_builder.build_system_prompt(
ticker=request.ticker or "",
active_widgets=request.active_widgets,
widget_data=request.widget_data,
)
prompt = _build_prompt(request.messages)
config = _resolve_llm_config(request.provider)
resolved = llm_router._resolve_config(prompt, config)
try:
content = await llm_router.generate(
prompt=prompt,
config=config,
system_prompt=system_prompt,
)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc))
except Exception:
logger.error("Chat completion error:\n%s", traceback.format_exc())
raise HTTPException(status_code=500, detail="Internal server error")
return ChatCompletionResponse(
content=content,
provider=resolved.provider.value,
model=resolved.model,
)
@router.get("/suggested", response_model=SuggestedQuestionsResponse)
async def get_suggested_questions(
ticker: str = "",
widgets: str = "",
) -> SuggestedQuestionsResponse:
"""Return suggested questions based on active widgets.
Args:
ticker: Active ticker symbol (currently unused, reserved for future).
widgets: Comma-separated list of active widget identifiers,
e.g. ``"dcf,financials,technical"``.
"""
active_widgets = [w.strip() for w in widgets.split(",") if w.strip()]
questions = context_builder.build_suggested_questions(active_widgets)
return SuggestedQuestionsResponse(questions=questions)
@router.post("/configure")
async def configure_llm(request: ConfigureRequest) -> dict[str, str]:
"""Configure an LLM provider with an API key.
Returns the list of currently available providers after configuration.
"""
try:
provider = LLMProvider(request.provider.lower())
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Unknown provider '{request.provider}'. "
f"Supported: gemini, claude, openai",
)
if not request.api_key:
raise HTTPException(status_code=400, detail="api_key is required")
llm_router.configure(provider, request.api_key)
available = [p.value for p in llm_router.get_available_providers()]
return {
"status": "ok",
"provider": provider.value,
"available_providers": ", ".join(available),
}
+139
View File
@@ -0,0 +1,139 @@
"""Crypto router -- live cryptocurrency prices from Bithumb (KRW) and Binance (USD)."""
from typing import List
from fastapi import APIRouter, HTTPException
from server.models.schemas import CryptoPrice
router = APIRouter()
# Top 20 symbols tracked by default
TOP_SYMBOLS = [
"BTC", "ETH", "BNB", "XRP", "SOL", "ADA", "DOGE", "AVAX", "DOT", "MATIC",
"LINK", "SHIB", "TRX", "UNI", "ATOM", "LTC", "ETC", "XLM", "NEAR", "APT",
]
# Bithumb uses different ticker names for some coins
_BITHUMB_MAP = {
"MATIC": "MATIC",
"NEAR": "NEAR",
"APT": "APT",
}
def _fetch_binance_prices(symbols: List[str]) -> dict:
"""Fetch USD prices from Binance API for the given symbols."""
import requests
url = "https://api.binance.com/api/v3/ticker/price"
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
except Exception:
return {}
# Build lookup: symbol (no USDT suffix) -> price
prices = {}
lookup = {item["symbol"]: float(item["price"]) for item in data}
for sym in symbols:
key = f"{sym.upper()}USDT"
if key in lookup:
prices[sym.upper()] = lookup[key]
return prices
def _fetch_bithumb_prices(symbols: List[str]) -> dict:
"""Fetch KRW prices from Bithumb public API."""
import requests
prices = {}
for sym in symbols:
bithumb_sym = _BITHUMB_MAP.get(sym.upper(), sym.upper())
url = f"https://api.bithumb.com/public/ticker/{bithumb_sym}_KRW"
try:
resp = requests.get(url, timeout=5)
resp.raise_for_status()
data = resp.json()
if data.get("status") == "0000":
closing = data.get("data", {}).get("closing_price")
if closing:
prices[sym.upper()] = float(closing)
except Exception:
continue
return prices
def _fetch_binance_24h_changes(symbols: List[str]) -> dict:
"""Fetch 24h percentage changes from Binance."""
import requests
url = "https://api.binance.com/api/v3/ticker/24hr"
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
except Exception:
return {}
changes = {}
lookup = {item["symbol"]: float(item.get("priceChangePercent", 0)) for item in data}
for sym in symbols:
key = f"{sym.upper()}USDT"
if key in lookup:
changes[sym.upper()] = lookup[key]
return changes
@router.get(
"/prices",
response_model=List[CryptoPrice],
summary="Top 20 crypto prices (Bithumb KRW + Binance USD)",
)
async def crypto_prices():
"""Return current prices for the top 20 cryptocurrencies.
USD prices come from Binance; KRW prices from Bithumb.
"""
try:
usd_prices = _fetch_binance_prices(TOP_SYMBOLS)
krw_prices = _fetch_bithumb_prices(TOP_SYMBOLS)
changes = _fetch_binance_24h_changes(TOP_SYMBOLS)
results: List[CryptoPrice] = []
for sym in TOP_SYMBOLS:
results.append(CryptoPrice(
symbol=sym,
name=sym,
price_usd=usd_prices.get(sym),
price_krw=krw_prices.get(sym),
change_24h_pct=changes.get(sym),
))
return results
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Crypto prices failed: {exc}") from exc
@router.get(
"/price/{symbol}",
response_model=CryptoPrice,
summary="Single crypto price",
)
async def crypto_price(symbol: str):
"""Return current price for a single cryptocurrency symbol."""
try:
sym = symbol.upper()
usd_prices = _fetch_binance_prices([sym])
krw_prices = _fetch_bithumb_prices([sym])
changes = _fetch_binance_24h_changes([sym])
return CryptoPrice(
symbol=sym,
name=sym,
price_usd=usd_prices.get(sym),
price_krw=krw_prices.get(sym),
change_24h_pct=changes.get(sym),
)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Crypto price failed: {exc}") from exc
+88
View File
@@ -0,0 +1,88 @@
"""Earnings router -- earnings history, upcoming dates, and transcripts."""
from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException
router = APIRouter()
def _safe_float(val, default=None):
if val is None:
return default
try:
import math
f = float(val)
return default if math.isnan(f) or math.isinf(f) else f
except (TypeError, ValueError):
return default
@router.get("/{ticker}/history", summary="Earnings history (EPS actual vs estimate)")
async def earnings_history(ticker: str) -> Dict[str, Any]:
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
earnings = t.earnings_history
if earnings is None or (hasattr(earnings, 'empty') and earnings.empty):
return {"ticker": ticker.upper(), "history": []}
history: List[Dict[str, Any]] = []
if hasattr(earnings, 'iterrows'):
for idx, row in earnings.iterrows():
history.append({
"date": str(idx)[:10],
"eps_actual": _safe_float(row.get("epsActual", row.get("Reported EPS"))),
"eps_estimate": _safe_float(row.get("epsEstimate", row.get("EPS Estimate"))),
"surprise": round(_safe_float(row.get("surprisePercent", row.get("Surprise(%)")), 0) * 100, 2),
})
return {"ticker": ticker.upper(), "history": history[-12:]}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Earnings history failed: {exc}") from exc
@router.get("/{ticker}/calendar", summary="Upcoming earnings date")
async def earnings_calendar(ticker: str) -> Dict[str, Any]:
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
cal = t.calendar
if cal is None:
return {"ticker": ticker.upper(), "next_earnings": None}
if isinstance(cal, dict):
earnings_date = cal.get("Earnings Date")
if isinstance(earnings_date, list) and earnings_date:
earnings_date = str(earnings_date[0])[:10]
elif earnings_date:
earnings_date = str(earnings_date)[:10]
return {
"ticker": ticker.upper(),
"next_earnings": earnings_date,
"revenue_estimate": _safe_float(cal.get("Revenue Average")),
"eps_estimate": _safe_float(cal.get("Earnings Average")),
}
return {"ticker": ticker.upper(), "next_earnings": None}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Earnings calendar failed: {exc}") from exc
@router.get("/{ticker}/quarterly", summary="Quarterly earnings data")
async def quarterly_earnings(ticker: str) -> Dict[str, Any]:
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
quarterly = t.quarterly_earnings
data: List[Dict[str, Any]] = []
if quarterly is not None and hasattr(quarterly, 'iterrows'):
for idx, row in quarterly.iterrows():
data.append({
"period": str(idx),
"revenue": _safe_float(row.get("Revenue")),
"earnings": _safe_float(row.get("Earnings")),
})
return {"ticker": ticker.upper(), "quarterly": data}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Quarterly earnings failed: {exc}") from exc
+95
View File
@@ -0,0 +1,95 @@
"""SEC EDGAR router -- 10-K section download, cache lookup, and comparison."""
from fastapi import APIRouter, HTTPException, Query
from server.models.schemas import (
EdgarSectionsResponse,
Item7Response,
CompareResponse,
)
router = APIRouter()
@router.get(
"/sections/{ticker}",
response_model=EdgarSectionsResponse,
summary="Get 10-K sections (cached or download)",
)
async def get_sections(
ticker: str,
email: str = Query(..., description="SEC EDGAR fair-access email"),
):
"""Return cleaned Item 1A, 3, 7, 8, 9A texts for *ticker*.
If the sections are already cached locally the download is skipped.
"""
try:
from server.services.sec_parser import get_10k_sections
sections, status = get_10k_sections(ticker.upper(), email)
return EdgarSectionsResponse(
status=status,
item1a=sections.get("item1a", ""),
item3=sections.get("item3", ""),
item7=sections.get("item7", ""),
item8=sections.get("item8", ""),
item9a=sections.get("item9a", ""),
)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"EDGAR download failed: {exc}") from exc
@router.get(
"/item7/{ticker}",
response_model=Item7Response,
summary="Get Item 7 (MD&A) text",
)
async def get_item7(
ticker: str,
email: str = Query(..., description="SEC EDGAR fair-access email"),
):
"""Return only the Item 7 Management Discussion & Analysis text."""
try:
from server.services.sec_parser import download_and_extract_item7_and_1a
_, _item1a, item7 = download_and_extract_item7_and_1a(ticker.upper(), email)
return Item7Response(item7=item7)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Item 7 extraction failed: {exc}") from exc
@router.get(
"/compare/{ticker}",
response_model=CompareResponse,
summary="Latest vs 3-year-ago Item 7 comparison",
)
async def compare_item7(
ticker: str,
email: str = Query(..., description="SEC EDGAR fair-access email"),
):
"""Download up to 5 10-Ks and return the latest and 3-year-ago Item 7 for
comparative analysis. Also returns the latest Item 1A.
"""
try:
from server.services.sec_parser import download_item7_latest_and_3y_ago
item1a, item7_latest, item7_3y_ago, has_comparison = (
download_item7_latest_and_3y_ago(ticker.upper(), email)
)
return CompareResponse(
item1a_latest=item1a or "",
item7_latest=item7_latest or "",
item7_3y_ago=item7_3y_ago,
has_comparison=has_comparison,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Comparison failed: {exc}") from exc
+159
View File
@@ -0,0 +1,159 @@
"""Analyst estimates router -- earnings, revenue, EPS, growth, and price targets."""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter
router = APIRouter()
def _safe_df_to_dict(df: Any) -> List[Dict[str, Any]]:
"""Convert a pandas DataFrame to a list of dicts, handling NaN safely.
Returns an empty list when the input is ``None`` or not a DataFrame.
"""
try:
import pandas as pd
if df is None or not isinstance(df, pd.DataFrame) or df.empty:
return []
return df.fillna(0).reset_index().to_dict(orient="records")
except Exception:
return []
def _safe_value(val: Any, default: Any = None) -> Any:
"""Return *val* unless it is NaN / None, in which case return *default*."""
import math
if val is None:
return default
try:
if math.isnan(val):
return default
except (TypeError, ValueError):
pass
return val
@router.get(
"/{ticker}",
summary="Full analyst estimates bundle",
)
async def full_estimates(ticker: str) -> Dict[str, Any]:
"""Return a comprehensive estimates bundle for *ticker*.
Includes earnings estimate, revenue estimate, EPS trend, growth
estimates, and price targets sourced from yfinance.
"""
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
info: Dict[str, Any] = t.info or {}
earnings_estimate = _safe_df_to_dict(
getattr(t, "earnings_estimate", None),
)
revenue_estimate = _safe_df_to_dict(
getattr(t, "revenue_estimate", None),
)
eps_trend = _safe_df_to_dict(
getattr(t, "eps_trend", None),
)
growth_estimates = _safe_df_to_dict(
getattr(t, "growth_estimates", None),
)
price_targets: Dict[str, Any] = {
"current": _safe_value(info.get("currentPrice")),
"mean": _safe_value(info.get("targetMeanPrice")),
"high": _safe_value(info.get("targetHighPrice")),
"low": _safe_value(info.get("targetLowPrice")),
"median": _safe_value(info.get("targetMedianPrice")),
"recommendation": info.get("recommendationKey", ""),
"num_analysts": _safe_value(info.get("numberOfAnalystOpinions"), 0),
}
return {
"ticker": ticker.upper(),
"earnings_estimate": earnings_estimate,
"revenue_estimate": revenue_estimate,
"eps_trend": eps_trend,
"growth_estimates": growth_estimates,
"price_targets": price_targets,
}
except Exception:
return {
"ticker": ticker.upper(),
"earnings_estimate": [],
"revenue_estimate": [],
"eps_trend": [],
"growth_estimates": [],
"price_targets": {"current": None, "mean": None, "high": None, "low": None, "median": None, "recommendation": "", "num_analysts": 0},
}
@router.get(
"/{ticker}/earnings-dates",
summary="Upcoming and past earnings dates",
)
async def earnings_dates(ticker: str) -> Dict[str, Any]:
"""Return upcoming and past earnings dates with surprise data.
Uses ``yfinance.Ticker.earnings_dates`` and ``earnings_history``.
"""
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
dates_df = getattr(t, "earnings_dates", None)
dates_records = _safe_df_to_dict(dates_df)
history_df = getattr(t, "earnings_history", None)
history_records = _safe_df_to_dict(history_df)
return {
"ticker": ticker.upper(),
"earnings_dates": dates_records,
"earnings_history": history_records,
}
except Exception:
return {
"ticker": ticker.upper(),
"earnings_dates": [],
"earnings_history": [],
}
@router.get(
"/{ticker}/growth",
summary="Growth estimates comparison",
)
async def growth_estimates(ticker: str) -> Dict[str, Any]:
"""Return growth estimates with current-quarter, next-quarter,
current-year, and next-year comparisons.
"""
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
growth_df = getattr(t, "growth_estimates", None)
growth_records = _safe_df_to_dict(growth_df)
eps_trend_df = getattr(t, "eps_trend", None)
eps_records = _safe_df_to_dict(eps_trend_df)
return {
"ticker": ticker.upper(),
"growth_estimates": growth_records,
"eps_trend": eps_records,
}
except Exception:
return {
"ticker": ticker.upper(),
"growth_estimates": [],
"eps_trend": [],
}
+258
View File
@@ -0,0 +1,258 @@
"""Financial statements router -- statements, highlights, and ratios."""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter
router = APIRouter()
def _df_to_periods(df: Any, max_periods: int = 5) -> List[Dict[str, Any]]:
"""Convert a yfinance / yahooquery financial DataFrame to a list of dicts.
Each dict represents one fiscal period. NaN values are replaced with
``None`` for clean JSON serialisation.
"""
try:
import pandas as pd
if df is None or not isinstance(df, pd.DataFrame) or df.empty:
return []
result = df.iloc[:, :max_periods].T
result.index = [str(i)[:10] for i in result.index]
records = result.reset_index().rename(columns={"index": "period"})
return records.where(records.notna(), None).to_dict(orient="records")
except Exception:
return []
def _calc_yoy_growth(series_list: List[Optional[float]]) -> List[Optional[float]]:
"""Return YoY growth rates for a list of period values.
The first element is always ``None`` (no prior period).
"""
growth: List[Optional[float]] = [None]
for i in range(1, len(series_list)):
prev = series_list[i - 1]
curr = series_list[i]
if prev and curr and prev != 0:
growth.append(round((curr - prev) / abs(prev) * 100, 2))
else:
growth.append(None)
return growth
def _safe_get(info: Dict[str, Any], key: str) -> Optional[float]:
"""Safely get a numeric value from *info*, returning None for NaN."""
import math
val = info.get(key)
if val is None:
return None
try:
if math.isnan(val):
return None
except (TypeError, ValueError):
return None
return float(val)
@router.get(
"/{ticker}/statements",
summary="Income statement, balance sheet, cash flow",
)
async def financial_statements(ticker: str) -> Dict[str, Any]:
"""Return income statement, balance sheet, and cash flow for *ticker*.
Attempts yahooquery first for richer data, then falls back to yfinance.
Includes up to 5 annual periods with YoY growth rates.
"""
try:
income_data: List[Dict[str, Any]] = []
balance_data: List[Dict[str, Any]] = []
cashflow_data: List[Dict[str, Any]] = []
# Use yfinance for clean annual data
import yfinance as yf
t = yf.Ticker(ticker.upper())
income_data = _df_to_periods(t.income_stmt)
balance_data = _df_to_periods(t.balance_sheet)
cashflow_data = _df_to_periods(t.cashflow)
# If yfinance gives no data, try yahooquery and filter to annual only
if not income_data:
try:
from yahooquery import Ticker as YQTicker # type: ignore[import-untyped]
import pandas as pd
yq = YQTicker(ticker.upper())
inc = yq.income_statement(frequency="a")
bal = yq.balance_sheet(frequency="a")
cf = yq.cash_flow(frequency="a")
if isinstance(inc, pd.DataFrame) and not inc.empty:
income_data = _df_to_periods(inc.T)
if isinstance(bal, pd.DataFrame) and not bal.empty:
balance_data = _df_to_periods(bal.T)
if isinstance(cf, pd.DataFrame) and not cf.empty:
cashflow_data = _df_to_periods(cf.T)
except ImportError:
pass
# Filter out TTM periods — keep only 12M/annual
def _filter_annual(records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
filtered = [r for r in records if r.get("periodType") != "TTM"]
return filtered if filtered else records
income_data = _filter_annual(income_data)
balance_data = _filter_annual(balance_data)
cashflow_data = _filter_annual(cashflow_data)
# Calculate YoY growth for revenue if available
revenue_values: List[Optional[float]] = []
for rec in income_data:
for key in ("TotalRevenue", "Total Revenue", "Revenue"):
if key in rec and rec[key] is not None:
revenue_values.append(rec[key])
break
else:
revenue_values.append(None)
revenue_growth = _calc_yoy_growth(revenue_values)
return {
"ticker": ticker.upper(),
"income_statement": income_data,
"balance_sheet": balance_data,
"cash_flow": cashflow_data,
"revenue_yoy_growth": revenue_growth,
}
except Exception:
return {
"ticker": ticker.upper(),
"income_statement": [],
"balance_sheet": [],
"cash_flow": [],
"revenue_yoy_growth": [],
}
@router.get(
"/{ticker}/highlights",
summary="Key financial metrics summary",
)
async def financial_highlights(ticker: str) -> Dict[str, Any]:
"""Return key financial metrics: revenue, margins, ROE, D/E, OCF.
Sourced from yfinance ``info`` for the most recent data.
"""
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
info: Dict[str, Any] = t.info or {}
highlights: Dict[str, Any] = {
"ticker": ticker.upper(),
"company_name": info.get("longName", info.get("shortName", "")),
"revenue": _safe_get(info, "totalRevenue"),
"revenue_per_share": _safe_get(info, "revenuePerShare"),
"gross_margin": _safe_get(info, "grossMargins"),
"operating_margin": _safe_get(info, "operatingMargins"),
"profit_margin": _safe_get(info, "profitMargins"),
"ebitda": _safe_get(info, "ebitda"),
"ebitda_margin": None,
"roe": _safe_get(info, "returnOnEquity"),
"roa": _safe_get(info, "returnOnAssets"),
"debt_to_equity": _safe_get(info, "debtToEquity"),
"current_ratio": _safe_get(info, "currentRatio"),
"operating_cash_flow": _safe_get(info, "operatingCashflow"),
"free_cash_flow": _safe_get(info, "freeCashflow"),
"book_value": _safe_get(info, "bookValue"),
"earnings_growth": _safe_get(info, "earningsGrowth"),
"revenue_growth": _safe_get(info, "revenueGrowth"),
}
# Derive EBITDA margin if both values exist
rev = highlights["revenue"]
ebitda = highlights["ebitda"]
if rev and ebitda and rev > 0:
highlights["ebitda_margin"] = round(ebitda / rev, 4)
return highlights
except Exception:
return {
"ticker": ticker.upper(),
"company_name": "",
"revenue": None, "revenue_per_share": None,
"gross_margin": None, "operating_margin": None, "profit_margin": None,
"ebitda": None, "ebitda_margin": None,
"roe": None, "roa": None,
"debt_to_equity": None, "current_ratio": None,
"operating_cash_flow": None, "free_cash_flow": None,
"book_value": None, "earnings_growth": None, "revenue_growth": None,
}
@router.get(
"/{ticker}/ratios",
summary="Valuation and financial ratios",
)
async def financial_ratios(ticker: str) -> Dict[str, Any]:
"""Return valuation ratios with 5-year averages.
Includes PER, PBR, PSR, P/OCF, EV/EBITDA, and PEG ratio.
"""
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
info: Dict[str, Any] = t.info or {}
# Current ratios
ratios: Dict[str, Any] = {
"ticker": ticker.upper(),
"trailing_pe": _safe_get(info, "trailingPE"),
"forward_pe": _safe_get(info, "forwardPE"),
"price_to_book": _safe_get(info, "priceToBook"),
"price_to_sales": _safe_get(info, "priceToSalesTrailing12Months"),
"enterprise_to_ebitda": _safe_get(info, "enterpriseToEbitda"),
"enterprise_to_revenue": _safe_get(info, "enterpriseToRevenue"),
"peg_ratio": _safe_get(info, "pegRatio"),
"price_to_ocf": None,
"ev": _safe_get(info, "enterpriseValue"),
"market_cap": _safe_get(info, "marketCap"),
}
# Calculate P/OCF
ocf = _safe_get(info, "operatingCashflow")
mkt_cap = _safe_get(info, "marketCap")
if ocf and mkt_cap and ocf > 0:
ratios["price_to_ocf"] = round(mkt_cap / ocf, 2)
# 5-year average PE from historical data
five_year_avg: Dict[str, Optional[float]] = {
"five_year_avg_pe": _safe_get(info, "fiveYearAvgDividendYield"),
"trailing_pe_5y_avg": None,
}
# Try to get peer average from industry
peer_avg: Dict[str, Optional[float]] = {
"industry_pe_avg": _safe_get(info, "industryPe") if "industryPe" in info else None,
}
ratios["averages"] = five_year_avg
ratios["peer_comparison"] = peer_avg
return ratios
except Exception:
return {
"ticker": ticker.upper(),
"trailing_pe": None, "forward_pe": None,
"price_to_book": None, "price_to_sales": None,
"enterprise_to_ebitda": None, "enterprise_to_revenue": None,
"peg_ratio": None, "price_to_ocf": None,
"ev": None, "market_cap": None,
"averages": {}, "peer_comparison": {},
}
+91
View File
@@ -0,0 +1,91 @@
"""FX router -- foreign exchange rates and historical data via yfinance."""
from typing import Dict, List
from fastapi import APIRouter, HTTPException
from server.models.schemas import FXRateResponse, FXHistoryResponse
router = APIRouter()
# Major FX pairs tracked by default (Yahoo Finance format: XXXYYY=X)
MAJOR_PAIRS = [
"USDKRW", "USDJPY", "EURUSD", "GBPUSD", "USDCNY",
"USDCHF", "AUDUSD", "USDCAD", "NZDUSD", "EURGBP",
]
def _yf_fx_symbol(pair: str) -> str:
"""Convert a pair like 'USDKRW' to the Yahoo Finance symbol 'USDKRW=X'."""
p = pair.upper().replace("=X", "").replace("/", "")
return f"{p}=X"
def _fetch_fx_rate(pair: str) -> float | None:
"""Fetch the latest FX rate for a single pair via yfinance."""
try:
import yfinance as yf
symbol = _yf_fx_symbol(pair)
ticker = yf.Ticker(symbol)
fast = getattr(ticker, "fast_info", None)
if fast:
price = getattr(fast, "last_price", None)
if price and float(price) > 0:
return float(price)
hist = ticker.history(period="1d")
if hist is not None and not hist.empty:
return float(hist["Close"].iloc[-1])
except Exception:
pass
return None
@router.get(
"/rates",
response_model=FXRateResponse,
summary="Major FX rates",
)
async def fx_rates():
"""Return current exchange rates for major currency pairs
(USD/KRW, USD/JPY, EUR/USD, GBP/USD, etc.).
"""
try:
rates: Dict[str, float] = {}
for pair in MAJOR_PAIRS:
rate = _fetch_fx_rate(pair)
if rate is not None:
rates[pair] = round(rate, 4)
return FXRateResponse(pair="MAJOR", rates=rates)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"FX rates failed: {exc}") from exc
@router.get(
"/history/{pair}",
response_model=FXHistoryResponse,
summary="1-year FX history",
)
async def fx_history(pair: str):
"""Return ~1 year of daily closing rates for the given currency pair.
*pair* should be in the format ``USDKRW``, ``EURUSD``, etc.
"""
try:
import yfinance as yf
symbol = _yf_fx_symbol(pair)
ticker = yf.Ticker(symbol)
hist = ticker.history(period="1y")
if hist is None or hist.empty:
raise HTTPException(status_code=404, detail=f"No history found for pair {pair}")
dates: List[str] = [d.strftime("%Y-%m-%d") for d in hist.index]
rates: List[float] = [round(float(v), 4) for v in hist["Close"]]
return FXHistoryResponse(pair=pair.upper(), dates=dates, rates=rates)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"FX history failed: {exc}") from exc
+69
View File
@@ -0,0 +1,69 @@
"""Insider trading router -- recent insider transactions from yfinance."""
from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException
router = APIRouter()
def _safe_float(val, default=None):
if val is None:
return default
try:
import math
f = float(val)
return default if math.isnan(f) or math.isinf(f) else f
except (TypeError, ValueError):
return default
@router.get("/{ticker}", summary="Recent insider transactions")
async def insider_transactions(ticker: str) -> Dict[str, Any]:
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
insiders = t.insider_transactions
if insiders is None or (hasattr(insiders, 'empty') and insiders.empty):
return {"ticker": ticker.upper(), "transactions": []}
transactions: List[Dict[str, Any]] = []
if hasattr(insiders, 'iterrows'):
for _, row in insiders.iterrows():
transactions.append({
"date": str(row.get("Start Date", ""))[:10] if "Start Date" in row.index else "",
"insider": str(row.get("Insider", "")),
"relation": str(row.get("Position", row.get("Relationship", ""))),
"transaction": str(row.get("Transaction", "")),
"shares": _safe_float(row.get("Shares")),
"value": _safe_float(row.get("Value")),
})
return {"ticker": ticker.upper(), "transactions": transactions[:30]}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Insider transactions failed: {exc}") from exc
@router.get("/{ticker}/holders", summary="Institutional and mutual fund holders")
async def holders(ticker: str) -> Dict[str, Any]:
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
institutional = []
inst = t.institutional_holders
if inst is not None and hasattr(inst, 'iterrows'):
for _, row in inst.iterrows():
institutional.append({
"holder": str(row.get("Holder", "")),
"shares": _safe_float(row.get("Shares")),
"value": _safe_float(row.get("Value")),
"pct_held": _safe_float(row.get("% Out")),
})
return {
"ticker": ticker.upper(),
"institutional": institutional[:15],
}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Holders failed: {exc}") from exc
@@ -0,0 +1,345 @@
"""Market Data router -- sector info, financial trends, comps, health metrics."""
from typing import Any, Dict, List
from fastapi import APIRouter, Query
router = APIRouter()
def _safe_float(val, default=0.0):
if val is None:
return default
try:
import math
f = float(val)
return default if math.isnan(f) or math.isinf(f) else f
except (TypeError, ValueError):
return default
@router.get("/indices", summary="Major market indices")
async def market_indices():
try:
import yfinance as yf
symbols = [
{"label": "S&P 500", "symbol": "^GSPC"},
{"label": "NASDAQ", "symbol": "^IXIC"},
{"label": "KOSPI", "symbol": "^KS11"},
{"label": "BTC", "symbol": "BTC-USD"},
]
results = []
for s in symbols:
try:
t = yf.Ticker(s["symbol"])
info = t.info or {}
price = _safe_float(info.get("regularMarketPrice") or info.get("previousClose"))
prev = _safe_float(info.get("regularMarketPreviousClose") or info.get("previousClose"))
change = price - prev if prev else 0
pct = (change / prev * 100) if prev else 0
results.append({
"label": s["label"],
"symbol": s["symbol"],
"price": f"{price:,.2f}" if price else "",
"change": f"{pct:+.2f}%",
"positive": pct >= 0,
})
except Exception:
results.append({"label": s["label"], "symbol": s["symbol"], "price": "", "change": "", "positive": True})
return results
except Exception:
return []
@router.get("/sector/{ticker}", summary="Sector and industry classification")
async def sector_industry(ticker: str):
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
info = t.info or {}
return {
"sector": info.get("sector", "N/A"),
"industry": info.get("industry", "N/A"),
"market_cap": _safe_float(info.get("marketCap")),
"pe_ratio": _safe_float(info.get("trailingPE")) or _safe_float(info.get("forwardPE")),
"dividend_yield": _safe_float(info.get("dividendYield")),
"beta": _safe_float(info.get("beta")),
"fifty_two_week_high": _safe_float(info.get("fiftyTwoWeekHigh")),
"fifty_two_week_low": _safe_float(info.get("fiftyTwoWeekLow")),
"current_price": _safe_float(info.get("currentPrice") or info.get("regularMarketPrice")),
}
except Exception:
return {"sector": "N/A", "industry": "N/A"}
@router.get("/trend/{ticker}", summary="5-year financial trend")
async def financial_trend(ticker: str):
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
fin = t.financials
cf = t.cashflow
if fin is None or fin.empty:
return {"years": [], "revenue": [], "net_income": [], "operating_margin": [], "fcf": []}
years = [str(c.year) for c in fin.columns[:5]]
revenue = [_safe_float(fin.loc["Total Revenue"][c]) if "Total Revenue" in fin.index else 0 for c in fin.columns[:5]]
net_income = [_safe_float(fin.loc["Net Income"][c]) if "Net Income" in fin.index else 0 for c in fin.columns[:5]]
op_margin = []
for i, c in enumerate(fin.columns[:5]):
oi = _safe_float(fin.loc["Operating Income"][c]) if "Operating Income" in fin.index else 0
rev = revenue[i] if i < len(revenue) else 1
op_margin.append(round(oi / rev * 100, 2) if rev else 0)
fcf_list = []
if cf is not None and not cf.empty:
for c in fin.columns[:5]:
if c in cf.columns:
ocf = _safe_float(cf.loc["Operating Cash Flow"][c]) if "Operating Cash Flow" in cf.index else 0
capex = _safe_float(cf.loc["Capital Expenditure"][c]) if "Capital Expenditure" in cf.index else 0
fcf_list.append(ocf + capex) # capex is negative
else:
fcf_list.append(0)
return {"years": years, "revenue": revenue, "net_income": net_income, "operating_margin": op_margin, "fcf": fcf_list}
except Exception:
return {"years": [], "revenue": [], "net_income": [], "operating_margin": [], "fcf": []}
@router.get("/comps", summary="Industry comparable companies")
async def industry_comps(tickers: str = Query(..., description="Comma-separated tickers")):
try:
import yfinance as yf
ticker_list = [t.strip().upper() for t in tickers.split(",") if t.strip()]
if not ticker_list:
return {"tickers": [], "data": []}
results = []
for sym in ticker_list:
t = yf.Ticker(sym)
info = t.info or {}
results.append({
"ticker": sym,
"forward_pe": _safe_float(info.get("forwardPE"), None),
"trailing_pe": _safe_float(info.get("trailingPE"), None),
"pb": _safe_float(info.get("priceToBook"), None),
"ev_ebitda": _safe_float(info.get("enterpriseToEbitda"), None),
"market_cap": _safe_float(info.get("marketCap"), None),
})
return {"tickers": ticker_list, "data": results}
except Exception:
return {"tickers": [], "data": []}
@router.get("/health/{ticker}", summary="DuPont, Altman Z-Score, Red Flags")
async def financial_health(ticker: str):
fallback = {"ticker": ticker.upper(), "dupont": {}, "altman_z": None, "red_flags": []}
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
info = t.info or {}
bs = t.balance_sheet
fin = t.financials
# DuPont Analysis
npm = _safe_float(info.get("profitMargins"))
roe = _safe_float(info.get("returnOnEquity"))
roa = _safe_float(info.get("returnOnAssets"))
total_assets = 0
total_equity = 0
total_revenue = 0
net_income = 0
if bs is not None and not bs.empty:
col = bs.columns[0]
total_assets = _safe_float(bs.loc["Total Assets"][col]) if "Total Assets" in bs.index else 0
se_keys = ["Stockholders Equity", "Total Stockholder Equity", "Common Stock Equity"]
for k in se_keys:
if k in bs.index:
total_equity = _safe_float(bs.loc[k][col])
break
if fin is not None and not fin.empty:
col = fin.columns[0]
total_revenue = _safe_float(fin.loc["Total Revenue"][col]) if "Total Revenue" in fin.index else 0
net_income = _safe_float(fin.loc["Net Income"][col]) if "Net Income" in fin.index else 0
asset_turnover = round(total_revenue / total_assets, 3) if total_assets else 0
equity_multiplier = round(total_assets / total_equity, 3) if total_equity else 0
dupont = {
"npm": round(npm, 4) if npm else round(net_income / total_revenue, 4) if total_revenue else 0,
"asset_turnover": asset_turnover,
"equity_multiplier": equity_multiplier,
"roe": round(roe, 4) if roe else round(npm * asset_turnover * equity_multiplier, 4) if npm else 0,
}
# Altman Z-Score (simplified)
altman_z = None
if bs is not None and not bs.empty and fin is not None and not fin.empty:
col_bs = bs.columns[0]
col_fin = fin.columns[0]
ca = _safe_float(bs.loc["Current Assets"][col_bs]) if "Current Assets" in bs.index else 0
cl = _safe_float(bs.loc["Current Liabilities"][col_bs]) if "Current Liabilities" in bs.index else 0
ta = total_assets
re_val = _safe_float(bs.loc["Retained Earnings"][col_bs]) if "Retained Earnings" in bs.index else 0
ebit = _safe_float(fin.loc["EBIT"][col_fin]) if "EBIT" in fin.index else _safe_float(fin.loc.get("Operating Income", {}).get(col_fin, 0))
mc = _safe_float(info.get("marketCap"))
tl_val = _safe_float(bs.loc["Total Liabilities Net Minority Interest"][col_bs]) if "Total Liabilities Net Minority Interest" in bs.index else (ta - total_equity)
rev = total_revenue
if ta > 0 and tl_val > 0:
wc_ta = (ca - cl) / ta
re_ta = re_val / ta
ebit_ta = ebit / ta
mc_tl = mc / tl_val if tl_val else 0
rev_ta = rev / ta
altman_z = round(1.2 * wc_ta + 1.4 * re_ta + 3.3 * ebit_ta + 0.6 * mc_tl + 1.0 * rev_ta, 2)
# Red Flags
red_flags = []
cr = _safe_float(info.get("currentRatio"))
de = _safe_float(info.get("debtToEquity"))
if cr and cr < 1.0:
red_flags.append(f"Low current ratio: {cr:.2f}")
if de and de > 200:
red_flags.append(f"High debt-to-equity: {de:.1f}%")
if npm and npm < 0:
red_flags.append("Negative profit margin")
if roe and roe < 0:
red_flags.append("Negative ROE")
return {"ticker": ticker.upper(), "dupont": dupont, "altman_z": altman_z, "red_flags": red_flags}
except Exception as e:
return fallback
@router.get("/piotroski/{ticker}", summary="Piotroski F-Score")
async def piotroski_score(ticker: str):
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
info = t.info or {}
fin = t.financials
bs = t.balance_sheet
cf = t.cashflow
score = 0
details = {}
if fin is None or fin.empty or bs is None or bs.empty:
return {"total": 0, "details": {}, "score": 0}
col = fin.columns[0]
prev_col = fin.columns[1] if len(fin.columns) > 1 else None
# 1. Positive ROA
ni = _safe_float(fin.loc["Net Income"][col]) if "Net Income" in fin.index else 0
ta = _safe_float(bs.loc["Total Assets"][col]) if "Total Assets" in bs.index else 1
roa = ni / ta if ta else 0
details["positive_roa"] = roa > 0
score += 1 if roa > 0 else 0
# 2. Positive Operating Cash Flow
ocf = 0
if cf is not None and not cf.empty and "Operating Cash Flow" in cf.index:
ocf = _safe_float(cf.loc["Operating Cash Flow"][cf.columns[0]])
details["positive_ocf"] = ocf > 0
score += 1 if ocf > 0 else 0
# 3. ROA improving
if prev_col is not None:
prev_ni = _safe_float(fin.loc["Net Income"][prev_col]) if "Net Income" in fin.index else 0
prev_ta = _safe_float(bs.loc["Total Assets"][prev_col]) if prev_col in bs.columns and "Total Assets" in bs.index else 1
prev_roa = prev_ni / prev_ta if prev_ta else 0
details["roa_improving"] = roa > prev_roa
score += 1 if roa > prev_roa else 0
else:
details["roa_improving"] = False
# 4. Cash flow > Net Income (accrual)
details["accrual"] = ocf > ni
score += 1 if ocf > ni else 0
# 5. Decreasing leverage
dle = _safe_float(info.get("debtToEquity", 0))
details["lower_leverage"] = dle < 100
score += 1 if dle < 100 else 0
# 6. Higher current ratio
cr = _safe_float(info.get("currentRatio", 0))
details["higher_liquidity"] = cr > 1.0
score += 1 if cr > 1.0 else 0
# 7. No dilution
shares = _safe_float(info.get("sharesOutstanding", 0))
details["no_dilution"] = True # simplified
score += 1
# 8. Higher gross margin
gm = _safe_float(info.get("grossMargins", 0))
details["higher_gross_margin"] = gm > 0.3
score += 1 if gm > 0.3 else 0
# 9. Higher asset turnover
rev = _safe_float(fin.loc["Total Revenue"][col]) if "Total Revenue" in fin.index else 0
at = rev / ta if ta else 0
details["higher_asset_turnover"] = at > 0.5
score += 1 if at > 0.5 else 0
return {"total": score, "details": details, "score": score}
except Exception:
return {"total": 0, "details": {}, "score": 0}
@router.get("/sankey/{ticker}", summary="Income statement Sankey data")
async def sankey_data(ticker: str):
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
fin = t.financials
if fin is None or fin.empty:
return {"nodes": [], "links": []}
col = fin.columns[0]
rev = _safe_float(fin.loc["Total Revenue"][col]) if "Total Revenue" in fin.index else 0
cogs = _safe_float(fin.loc["Cost Of Revenue"][col]) if "Cost Of Revenue" in fin.index else 0
gp = rev - cogs
opex = _safe_float(fin.loc["Operating Expense"][col]) if "Operating Expense" in fin.index else 0
oi = _safe_float(fin.loc["Operating Income"][col]) if "Operating Income" in fin.index else gp - opex
ni = _safe_float(fin.loc["Net Income"][col]) if "Net Income" in fin.index else 0
tax_other = oi - ni
nodes = [
{"name": "Revenue", "value": rev},
{"name": "COGS", "value": cogs},
{"name": "Gross Profit", "value": gp},
{"name": "Operating Expenses", "value": opex},
{"name": "Operating Income", "value": oi},
{"name": "Tax & Other", "value": abs(tax_other)},
{"name": "Net Income", "value": ni},
]
return {"nodes": nodes}
except Exception:
return {"nodes": []}
@router.get("/radar/{ticker}", summary="Radar chart metrics")
async def radar_metrics(ticker: str):
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
info = t.info or {}
return {
"roe": _safe_float(info.get("returnOnEquity", 0)) * 100,
"roa": _safe_float(info.get("returnOnAssets", 0)) * 100,
"gross_margin": _safe_float(info.get("grossMargins", 0)) * 100,
"current_ratio": _safe_float(info.get("currentRatio", 0)),
"revenue_growth": _safe_float(info.get("revenueGrowth", 0)) * 100,
}
except Exception:
return {}
+180
View File
@@ -0,0 +1,180 @@
"""News router -- aggregated financial news from Finviz and Google News RSS."""
from typing import List
from fastapi import APIRouter, HTTPException, Query
from server.models.schemas import NewsItem
router = APIRouter()
def _fetch_finviz_news(ticker: str) -> List[dict]:
"""Scrape recent headlines from Finviz news table for *ticker*."""
import requests
from bs4 import BeautifulSoup
url = f"https://finviz.com/quote.ashx?t={ticker.upper()}&ty=c&p=d&b=1"
headers = {"User-Agent": "ATLAS-Terminal/1.0"}
try:
resp = requests.get(url, headers=headers, timeout=10)
resp.raise_for_status()
except Exception:
return []
soup = BeautifulSoup(resp.text, "html.parser")
news_table = soup.find(id="news-table")
if not news_table:
return []
items: List[dict] = []
current_date = ""
for row in news_table.find_all("tr"):
cells = row.find_all("td")
if len(cells) < 2:
continue
date_cell = cells[0].get_text(strip=True)
if len(date_cell) > 8:
# Contains date + time, e.g. "Mar-18-26 08:30AM"
current_date = date_cell
else:
# Time only -- reuse last date
current_date = current_date.split(" ")[0] + " " + date_cell if current_date else date_cell
link_tag = cells[1].find("a")
if not link_tag:
continue
title = link_tag.get_text(strip=True)
href = link_tag.get("href", "")
source_span = cells[1].find("span")
source = source_span.get_text(strip=True) if source_span else ""
items.append({
"title": title,
"source": source,
"url": href,
"published_at": current_date,
"summary": "",
})
return items[:20]
def _fetch_google_news_rss(ticker: str) -> List[dict]:
"""Fetch recent headlines from Google News RSS for *ticker*."""
import requests
from xml.etree import ElementTree
url = f"https://news.google.com/rss/search?q={ticker.upper()}+stock&hl=en-US&gl=US&ceid=US:en"
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
except Exception:
return []
items: List[dict] = []
try:
root = ElementTree.fromstring(resp.content)
for item in root.iter("item"):
title = (item.findtext("title") or "").strip()
link = (item.findtext("link") or "").strip()
pub_date = (item.findtext("pubDate") or "").strip()
source_el = item.find("source")
source = source_el.text.strip() if source_el is not None and source_el.text else ""
items.append({
"title": title,
"source": source,
"url": link,
"published_at": pub_date,
"summary": "",
})
except Exception:
pass
return items[:20]
@router.get(
"/{ticker}",
response_model=List[NewsItem],
summary="Aggregated news (Finviz + Google News)",
)
async def get_news(ticker: str):
"""Return up to 40 recent news articles for *ticker*, merged from
Finviz and Google News RSS feeds. Duplicates are removed by title.
"""
try:
finviz = _fetch_finviz_news(ticker.upper())
google = _fetch_google_news_rss(ticker.upper())
seen_titles: set = set()
merged: List[dict] = []
for item in finviz + google:
t = item.get("title", "").strip().lower()
if t and t not in seen_titles:
seen_titles.add(t)
merged.append(item)
return [NewsItem(**item) for item in merged[:40]]
except Exception as exc:
raise HTTPException(status_code=500, detail=f"News fetch failed: {exc}") from exc
@router.get(
"/{ticker}/ai-summary",
summary="AI-summarized news (optional)",
)
async def ai_news_summary(
ticker: str,
api_key: str = Query("", description="Google Gemini API key (optional)"),
):
"""Fetch news and optionally generate an AI summary of the top headlines.
If *api_key* is provided, Gemini produces a short executive summary.
Otherwise, the raw headlines are returned.
"""
try:
finviz = _fetch_finviz_news(ticker.upper())
google = _fetch_google_news_rss(ticker.upper())
seen_titles: set = set()
headlines: List[str] = []
all_items: List[dict] = []
for item in finviz + google:
t = item.get("title", "").strip()
tl = t.lower()
if tl and tl not in seen_titles:
seen_titles.add(tl)
headlines.append(t)
all_items.append(item)
headlines = headlines[:20]
all_items = all_items[:20]
if not api_key or not api_key.strip():
return {
"ticker": ticker.upper(),
"summary": None,
"headlines": headlines,
"items": all_items,
}
# AI summary via Gemini
from app import get_gemini_model, _generate_with_retry # type: ignore[import-untyped]
model = get_gemini_model(api_key)
headline_text = "\n".join(f"- {h}" for h in headlines)
prompt = f"""You are a financial news analyst. Below are the latest headlines for {ticker.upper()}.
Provide a concise 3-5 sentence executive summary of the overall sentiment and key themes.
Headlines:
{headline_text}"""
response = _generate_with_retry(model, prompt, {"temperature": 0.2, "max_output_tokens": 512})
summary = (response.text or "").strip() if response else ""
return {
"ticker": ticker.upper(),
"summary": summary,
"headlines": headlines,
"items": all_items,
}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"AI news summary failed: {exc}") from exc
+230
View File
@@ -0,0 +1,230 @@
"""Portfolio router -- position management, OCR screenshot upload, summary."""
import json
import uuid
from pathlib import Path
from typing import List
from fastapi import APIRouter, HTTPException, UploadFile, File
from server.models.schemas import (
PortfolioPosition,
PortfolioPositionCreate,
PortfolioSummary,
)
router = APIRouter()
# Simple file-based persistence (production would use Supabase / Postgres)
_PORTFOLIO_FILE = Path(__file__).resolve().parent.parent.parent / "data" / "portfolio.json"
def _load_positions() -> List[dict]:
"""Load positions from the JSON store."""
if not _PORTFOLIO_FILE.exists():
return []
try:
with open(_PORTFOLIO_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return []
def _save_positions(positions: List[dict]) -> None:
"""Persist positions to the JSON store."""
_PORTFOLIO_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(_PORTFOLIO_FILE, "w", encoding="utf-8") as f:
json.dump(positions, f, ensure_ascii=False, indent=2)
def _get_current_price(ticker: str) -> float | None:
"""Fetch the latest market price for *ticker*."""
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
fast = getattr(t, "fast_info", None)
if fast:
price = getattr(fast, "last_price", None)
if price and float(price) > 0:
return float(price)
hist = t.history(period="1d")
if hist is not None and not hist.empty:
return float(hist["Close"].iloc[-1])
except Exception:
pass
return None
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get(
"/positions",
response_model=List[PortfolioPosition],
summary="List portfolio positions",
)
async def list_positions():
"""Return all portfolio positions (without live pricing)."""
try:
positions = _load_positions()
return [PortfolioPosition(**p) for p in positions]
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to load positions: {exc}") from exc
@router.post(
"/positions",
response_model=PortfolioPosition,
summary="Add a portfolio position",
)
async def add_position(pos: PortfolioPositionCreate):
"""Add a new position to the portfolio."""
try:
positions = _load_positions()
new_pos = {
"id": str(uuid.uuid4()),
"ticker": pos.ticker.upper(),
"company_name": pos.company_name,
"quantity": pos.quantity,
"avg_price": pos.avg_price,
"currency": pos.currency,
"source": pos.source,
}
positions.append(new_pos)
_save_positions(positions)
return PortfolioPosition(**new_pos)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to add position: {exc}") from exc
@router.delete(
"/positions/{position_id}",
summary="Remove a portfolio position",
)
async def remove_position(position_id: str):
"""Delete a position by its unique ID."""
try:
positions = _load_positions()
original_len = len(positions)
positions = [p for p in positions if p.get("id") != position_id]
if len(positions) == original_len:
raise HTTPException(status_code=404, detail=f"Position {position_id} not found.")
_save_positions(positions)
return {"deleted": position_id}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to remove position: {exc}") from exc
@router.post(
"/screenshot",
summary="Upload screenshot for OCR analysis",
)
async def upload_screenshot(file: UploadFile = File(...)):
"""Accept a screenshot image (PNG/JPG) and attempt to extract portfolio
positions via OCR. Returns the recognised text and any parsed positions.
This is a best-effort feature; parsing accuracy depends on the
screenshot layout.
"""
try:
contents = await file.read()
# Try pytesseract for OCR
try:
from PIL import Image
import pytesseract
import io
image = Image.open(io.BytesIO(contents))
text = pytesseract.image_to_string(image)
except ImportError:
text = "(OCR not available -- install pytesseract and Pillow)"
except Exception as ocr_err:
text = f"(OCR failed: {ocr_err})"
return {
"filename": file.filename,
"size": len(contents),
"ocr_text": text,
"parsed_positions": [], # Future: parse text into positions
}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Screenshot processing failed: {exc}") from exc
@router.get("/risk", summary="Portfolio risk metrics (VaR, Sharpe, MDD)")
async def portfolio_risk():
"""Compute portfolio risk metrics from current positions."""
try:
from server.services.risk_metrics import compute_portfolio_risk
positions = _load_positions()
if not positions:
return {"error": "No positions in portfolio"}
result = compute_portfolio_risk(positions)
return result
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Risk metrics failed: {exc}") from exc
@router.get(
"/summary",
response_model=PortfolioSummary,
summary="Portfolio summary with current prices",
)
async def portfolio_summary():
"""Return all positions enriched with current market prices,
market values, and P&L.
"""
try:
positions = _load_positions()
enriched: List[PortfolioPosition] = []
total_value = 0.0
total_cost = 0.0
for p in positions:
ticker = p.get("ticker", "")
quantity = float(p.get("quantity", 0))
avg_price = float(p.get("avg_price", 0))
cost = quantity * avg_price
total_cost += cost
current_price = _get_current_price(ticker)
market_value = (quantity * current_price) if current_price else None
pnl = (market_value - cost) if market_value is not None else None
pnl_pct = (pnl / cost * 100) if (pnl is not None and cost > 0) else None
if market_value is not None:
total_value += market_value
enriched.append(PortfolioPosition(
id=p.get("id"),
ticker=ticker,
company_name=p.get("company_name", ""),
quantity=quantity,
avg_price=avg_price,
currency=p.get("currency", "USD"),
source=p.get("source", "manual"),
current_price=current_price,
market_value=market_value,
pnl=pnl,
pnl_pct=round(pnl_pct, 2) if pnl_pct is not None else None,
))
total_pnl = total_value - total_cost
total_pnl_pct = (total_pnl / total_cost * 100) if total_cost > 0 else None
return PortfolioSummary(
total_value=round(total_value, 2),
total_cost=round(total_cost, 2),
total_pnl=round(total_pnl, 2),
total_pnl_pct=round(total_pnl_pct, 2) if total_pnl_pct is not None else None,
positions=enriched,
)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Portfolio summary failed: {exc}") from exc
+305
View File
@@ -0,0 +1,305 @@
"""Technical analysis router -- indicators, chart data, Fibonacci, Ichimoku."""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
router = APIRouter()
@router.get(
"/{ticker}/indicators",
summary="Technical indicators (RSI, SMA, EMA, MACD, BB, ATR)",
)
async def technical_indicators(ticker: str) -> Dict[str, Any]:
"""Calculate and return common technical indicators for *ticker*.
Returns RSI(14), SMA(20/50/200), EMA(12/26), MACD with signal and
histogram, Bollinger Bands (20,2), and ATR(14).
"""
try:
import yfinance as yf
import ta # type: ignore[import-untyped]
df = yf.download(ticker.upper(), period="1y", interval="1d", progress=False)
if df.empty:
raise HTTPException(status_code=404, detail=f"No data for {ticker}")
# Flatten MultiIndex columns if present
if hasattr(df.columns, "nlevels") and df.columns.nlevels > 1:
df.columns = df.columns.get_level_values(0)
close = df["Close"]
high = df["High"]
low = df["Low"]
# RSI
rsi_indicator = ta.momentum.RSIIndicator(close=close, window=14)
rsi_val = rsi_indicator.rsi().iloc[-1]
# SMA
sma_20 = close.rolling(window=20).mean().iloc[-1]
sma_50 = close.rolling(window=50).mean().iloc[-1]
sma_200 = close.rolling(window=200).mean().iloc[-1] if len(close) >= 200 else None
# EMA
ema_12 = close.ewm(span=12, adjust=False).mean().iloc[-1]
ema_26 = close.ewm(span=26, adjust=False).mean().iloc[-1]
# MACD
macd_indicator = ta.trend.MACD(close=close)
macd_line = macd_indicator.macd().iloc[-1]
macd_signal = macd_indicator.macd_signal().iloc[-1]
macd_hist = macd_indicator.macd_diff().iloc[-1]
# Bollinger Bands
bb = ta.volatility.BollingerBands(close=close, window=20, window_dev=2)
bb_upper = bb.bollinger_hband().iloc[-1]
bb_middle = bb.bollinger_mavg().iloc[-1]
bb_lower = bb.bollinger_lband().iloc[-1]
# ATR
atr_indicator = ta.volatility.AverageTrueRange(
high=high, low=low, close=close, window=14,
)
atr_val = atr_indicator.average_true_range().iloc[-1]
current_price = float(close.iloc[-1])
return {
"ticker": ticker.upper(),
"current_price": current_price,
"rsi_14": round(float(rsi_val), 2),
"sma": {
"sma_20": round(float(sma_20), 2),
"sma_50": round(float(sma_50), 2),
"sma_200": round(float(sma_200), 2) if sma_200 is not None else None,
},
"ema": {
"ema_12": round(float(ema_12), 2),
"ema_26": round(float(ema_26), 2),
},
"macd": {
"macd": round(float(macd_line), 4),
"signal": round(float(macd_signal), 4),
"histogram": round(float(macd_hist), 4),
},
"bollinger_bands": {
"upper": round(float(bb_upper), 2),
"middle": round(float(bb_middle), 2),
"lower": round(float(bb_lower), 2),
},
"atr_14": round(float(atr_val), 2),
}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
status_code=500,
detail=f"Technical indicators failed: {exc}",
) from exc
@router.get(
"/{ticker}/chart-data",
summary="OHLCV data for charting",
)
async def chart_data(
ticker: str,
period: str = Query(
default="6mo",
description="Data period: 1d,5d,1mo,3mo,6mo,1y,2y,5y",
),
interval: str = Query(
default="1d",
description="Data interval: 1m,5m,15m,1h,1d,1wk",
),
) -> Dict[str, Any]:
"""Return OHLCV data formatted for TradingView Lightweight Charts.
Each bar is ``{time, open, high, low, close, volume}``.
"""
try:
import yfinance as yf
valid_periods = {"1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y"}
valid_intervals = {"1m", "5m", "15m", "1h", "1d", "1wk"}
if period not in valid_periods:
raise HTTPException(
status_code=400,
detail=f"Invalid period '{period}'. Must be one of {valid_periods}",
)
if interval not in valid_intervals:
raise HTTPException(
status_code=400,
detail=f"Invalid interval '{interval}'. Must be one of {valid_intervals}",
)
df = yf.download(
ticker.upper(),
period=period,
interval=interval,
progress=False,
)
if df.empty:
raise HTTPException(status_code=404, detail=f"No data for {ticker}")
# Flatten MultiIndex columns if present
if hasattr(df.columns, "nlevels") and df.columns.nlevels > 1:
df.columns = df.columns.get_level_values(0)
bars: List[Dict[str, Any]] = []
for idx, row in df.iterrows():
time_str = str(idx)[:10] if interval in {"1d", "1wk"} else str(idx)
bars.append({
"time": time_str,
"open": round(float(row["Open"]), 4),
"high": round(float(row["High"]), 4),
"low": round(float(row["Low"]), 4),
"close": round(float(row["Close"]), 4),
"volume": int(row["Volume"]),
})
return {
"ticker": ticker.upper(),
"period": period,
"interval": interval,
"bars": bars,
}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
status_code=500,
detail=f"Chart data fetch failed: {exc}",
) from exc
@router.get(
"/{ticker}/fibonacci",
summary="Fibonacci retracement levels",
)
async def fibonacci_levels(ticker: str) -> Dict[str, Any]:
"""Return Fibonacci retracement levels based on the 52-week high and low.
Levels: 0%, 23.6%, 38.2%, 50%, 61.8%, 78.6%, 100%.
"""
try:
import yfinance as yf
df = yf.download(ticker.upper(), period="1y", interval="1d", progress=False)
if df.empty:
raise HTTPException(status_code=404, detail=f"No data for {ticker}")
# Flatten MultiIndex columns if present
if hasattr(df.columns, "nlevels") and df.columns.nlevels > 1:
df.columns = df.columns.get_level_values(0)
high_52w: float = float(df["High"].max())
low_52w: float = float(df["Low"].min())
diff: float = high_52w - low_52w
ratios = [0.0, 0.236, 0.382, 0.5, 0.618, 0.786, 1.0]
levels: Dict[str, float] = {}
for r in ratios:
label = f"{r * 100:.1f}%"
levels[label] = round(high_52w - diff * r, 2)
current_price = float(df["Close"].iloc[-1])
return {
"ticker": ticker.upper(),
"high_52w": round(high_52w, 2),
"low_52w": round(low_52w, 2),
"current_price": round(current_price, 2),
"levels": levels,
}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
status_code=500,
detail=f"Fibonacci levels failed: {exc}",
) from exc
@router.get(
"/{ticker}/ichimoku",
summary="Ichimoku cloud data",
)
async def ichimoku_cloud(ticker: str) -> Dict[str, Any]:
"""Return Ichimoku cloud components for *ticker*.
Components: Tenkan-sen (9), Kijun-sen (26), Senkou Span A,
Senkou Span B (52), and Chikou Span.
"""
try:
import yfinance as yf
import pandas as pd
df = yf.download(ticker.upper(), period="1y", interval="1d", progress=False)
if df.empty:
raise HTTPException(status_code=404, detail=f"No data for {ticker}")
# Flatten MultiIndex columns if present
if hasattr(df.columns, "nlevels") and df.columns.nlevels > 1:
df.columns = df.columns.get_level_values(0)
high = df["High"]
low = df["Low"]
close = df["Close"]
# Tenkan-sen (Conversion Line): (9-period high + 9-period low) / 2
nine_high = high.rolling(window=9).max()
nine_low = low.rolling(window=9).min()
tenkan = (nine_high + nine_low) / 2
# Kijun-sen (Base Line): (26-period high + 26-period low) / 2
k_high = high.rolling(window=26).max()
k_low = low.rolling(window=26).min()
kijun = (k_high + k_low) / 2
# Senkou Span A (Leading Span A): (Tenkan + Kijun) / 2, shifted 26
senkou_a = ((tenkan + kijun) / 2).shift(26)
# Senkou Span B (Leading Span B): (52-period high + low) / 2, shifted 26
b_high = high.rolling(window=52).max()
b_low = low.rolling(window=52).min()
senkou_b = ((b_high + b_low) / 2).shift(26)
# Chikou Span (Lagging Span): Close shifted back 26 periods
chikou = close.shift(-26)
# Take last 100 data points for response
n = min(100, len(df))
dates = [str(d)[:10] for d in df.index[-n:]]
def _to_list(series: pd.Series) -> List[Optional[float]]:
"""Convert the last *n* values of a series to a list of floats."""
vals = series.iloc[-n:]
result: List[Optional[float]] = []
for v in vals:
try:
result.append(round(float(v), 2))
except (ValueError, TypeError):
result.append(None)
return result
return {
"ticker": ticker.upper(),
"dates": dates,
"tenkan_sen": _to_list(tenkan),
"kijun_sen": _to_list(kijun),
"senkou_span_a": _to_list(senkou_a),
"senkou_span_b": _to_list(senkou_b),
"chikou_span": _to_list(chikou),
"close": _to_list(close),
}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
status_code=500,
detail=f"Ichimoku cloud failed: {exc}",
) from exc
+298
View File
@@ -0,0 +1,298 @@
"""Valuation router -- DCF calculation, smart defaults, analyst consensus,
sensitivity analysis, Monte Carlo simulation, reverse DCF, and tornado charts."""
from fastapi import APIRouter
from pydantic import BaseModel
from typing import Optional, Dict, Any, List
router = APIRouter()
def _safe_float(val, default=0.0):
if val is None:
return default
try:
import math
f = float(val)
return default if math.isnan(f) or math.isinf(f) else f
except (TypeError, ValueError):
return default
class DCFInputsBody(BaseModel):
ticker: str = ""
base_fcf: float = 0
fcf: float = 0 # alias
shares: float = 0
shares_outstanding: float = 0 # alias
total_debt: float = 0
cash: float = 0
wacc: float = 0.09
terminal_growth: float = 0.025
fcf_growth: float = 0.10
fcf_growth_rate: float = 0 # alias
@router.get("/dcf-inputs/{ticker}", summary="Auto-fill DCF inputs from market data")
async def dcf_inputs(ticker: str):
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
info = t.info or {}
cf = t.cashflow
bs = t.balance_sheet
fcf = _safe_float(info.get("freeCashflow"))
if not fcf and cf is not None and not cf.empty:
col = cf.columns[0]
ocf = _safe_float(cf.loc["Operating Cash Flow"][col]) if "Operating Cash Flow" in cf.index else 0
capex = _safe_float(cf.loc["Capital Expenditure"][col]) if "Capital Expenditure" in cf.index else 0
fcf = ocf + capex
total_debt = _safe_float(info.get("totalDebt"))
cash = _safe_float(info.get("totalCash"))
shares = _safe_float(info.get("sharesOutstanding"))
return {"fcf": fcf, "total_debt": total_debt, "cash": cash, "shares": shares}
except Exception:
return {"fcf": None, "total_debt": 0, "cash": 0, "shares": None}
@router.post("/dcf", summary="Calculate 3-scenario DCF valuation")
async def calculate_dcf(inputs: DCFInputsBody):
try:
import yfinance as yf
base_fcf = inputs.base_fcf or inputs.fcf
_shares = inputs.shares or inputs.shares_outstanding
wacc = inputs.wacc
tg = inputs.terminal_growth
fcf_g = inputs.fcf_growth or inputs.fcf_growth_rate or 0.10
projection_years = 10
def _dcf(fcf, w, g, tgr):
if w <= tgr:
return None
projected = []
current = fcf
for _ in range(projection_years):
current *= (1 + g)
projected.append(current)
terminal = projected[-1] * (1 + tgr) / (w - tgr)
pv_fcfs = sum(f / (1 + w) ** (i + 1) for i, f in enumerate(projected))
pv_terminal = terminal / (1 + w) ** projection_years
ev = pv_fcfs + pv_terminal
eq = ev - inputs.total_debt + inputs.cash
per_share = eq / _shares if _shares else None
return per_share
base_val = _dcf(base_fcf, wacc, fcf_g, tg)
bull_val = _dcf(base_fcf, max(wacc - 0.005, tg + 0.005), fcf_g + 0.02, tg)
bear_val = _dcf(base_fcf, wacc + 0.01, max(fcf_g - 0.03, tg + 0.005), tg)
# Get current price
current_price = None
ticker_sym = inputs.ticker or ""
if ticker_sym:
try:
t = yf.Ticker(ticker_sym.upper())
current_price = _safe_float(t.info.get("currentPrice") or t.info.get("regularMarketPrice"))
except Exception:
pass
def _upside(val):
if val is None or current_price is None or current_price == 0:
return 0
return round((val / current_price - 1) * 100, 1)
return {
"base": round(base_val, 2) if base_val else None,
"bull": round(bull_val, 2) if bull_val else None,
"bear": round(bear_val, 2) if bear_val else None,
"current_price": current_price,
"scenarios": {
"bull": {"intrinsic_value": round(bull_val, 2) if bull_val else None, "upside": _upside(bull_val)},
"base": {"intrinsic_value": round(base_val, 2) if base_val else None, "upside": _upside(base_val)},
"bear": {"intrinsic_value": round(bear_val, 2) if bear_val else None, "upside": _upside(bear_val)},
},
}
except Exception:
return {"base": None, "bull": None, "bear": None, "current_price": None}
@router.get("/smart-defaults/{ticker}", summary="Smart DCF defaults")
async def smart_defaults(ticker: str):
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
info = t.info or {}
sector = info.get("sector", "N/A")
industry = info.get("industry", "N/A")
# Sector-based WACC heuristics
wacc_map = {
"Technology": 10, "Healthcare": 9, "Financial Services": 8,
"Consumer Cyclical": 9, "Consumer Defensive": 7.5,
"Industrials": 8.5, "Energy": 10.5, "Utilities": 6.5,
"Real Estate": 7, "Communication Services": 9, "Basic Materials": 9,
}
wacc = wacc_map.get(sector, 9.0)
rev_growth = _safe_float(info.get("revenueGrowth", 0.1)) * 100
fcf_growth = min(max(rev_growth, 3), 35)
return {
"wacc": wacc, "terminal_growth": 2.5, "fcf_growth": round(fcf_growth, 1),
"sector": sector, "industry": industry,
}
except Exception:
return {"wacc": 9, "terminal_growth": 2.5, "fcf_growth": 10, "sector": "N/A", "industry": "N/A"}
class SensitivityBody(BaseModel):
fcf: float = 0
total_debt: float = 0
cash: float = 0
shares: float = 0
wacc: float = 0.09
terminal_growth: float = 0.025
fcf_growth: float = 0.10
class MonteCarloBody(BaseModel):
ticker: str = ""
fcf: float = 0
wacc_mean: float = 0.09
wacc_std: float = 0.015
growth_mean: float = 0.10
growth_std: float = 0.03
term_growth: float = 0.025
total_debt: float = 0
cash: float = 0
shares: float = 0
n_simulations: int = 5000
class ReverseDCFBody(BaseModel):
ticker: str = ""
fcf: float = 0
shares: float = 0
total_debt: float = 0
cash: float = 0
wacc: float = 0.09
terminal_growth: float = 0.025
@router.post("/sensitivity", summary="Sensitivity matrix (WACC vs Terminal Growth)")
async def sensitivity_analysis(body: SensitivityBody):
try:
from server.services.sensitivity import build_sensitivity_matrix
result = build_sensitivity_matrix(
fcf=body.fcf, total_debt=body.total_debt, cash=body.cash,
shares=body.shares, base_wacc=body.wacc, base_tg=body.terminal_growth,
fcf_growth=body.fcf_growth,
)
return result
except Exception as exc:
return {"error": str(exc)}
@router.post("/tornado", summary="Tornado chart data")
async def tornado_chart(body: SensitivityBody):
try:
from server.services.sensitivity import build_tornado_data
result = build_tornado_data(
fcf=body.fcf, wacc=body.wacc, tg=body.terminal_growth,
growth=body.fcf_growth, debt=body.total_debt,
cash=body.cash, shares=body.shares,
)
return {"data": result}
except Exception as exc:
return {"error": str(exc)}
@router.post("/monte-carlo", summary="Monte Carlo DCF simulation")
async def monte_carlo_dcf(body: MonteCarloBody):
try:
import yfinance as yf
from server.services.monte_carlo import run_monte_carlo_dcf
current_price = None
if body.ticker:
try:
t = yf.Ticker(body.ticker.upper())
current_price = _safe_float(t.info.get("currentPrice") or t.info.get("regularMarketPrice"))
except Exception:
pass
result = run_monte_carlo_dcf(
fcf=body.fcf, wacc_mean=body.wacc_mean, wacc_std=body.wacc_std,
growth_mean=body.growth_mean, growth_std=body.growth_std,
term_growth=body.term_growth, total_debt=body.total_debt,
cash=body.cash, shares=body.shares, n_simulations=body.n_simulations,
current_price=current_price,
)
values = result.get("values", [])
if values:
import numpy as np
arr = np.array(values)
counts, bin_edges = np.histogram(arr, bins=50)
result["histogram"] = {
"counts": counts.tolist(),
"bin_edges": [round(b, 2) for b in bin_edges.tolist()],
}
result["values"] = []
return result
except Exception as exc:
return {"error": str(exc)}
@router.post("/reverse-dcf", summary="Reverse DCF — implied growth rate")
async def reverse_dcf_endpoint(body: ReverseDCFBody):
try:
import yfinance as yf
from server.services.dcf_engine import reverse_dcf
current_price = None
if body.ticker:
try:
t = yf.Ticker(body.ticker.upper())
current_price = _safe_float(t.info.get("currentPrice") or t.info.get("regularMarketPrice"))
except Exception:
pass
if not current_price:
return {"implied_growth": None, "current_price": None, "error": "No current price"}
implied = reverse_dcf(
current_price=current_price, shares=body.shares,
total_debt=body.total_debt, cash=body.cash,
wacc=body.wacc, term_growth=body.terminal_growth,
fcf_base=body.fcf,
)
return {
"implied_growth": round(implied * 100, 2) if implied is not None else None,
"current_price": current_price,
}
except Exception as exc:
return {"error": str(exc)}
@router.get("/consensus/{ticker}", summary="Analyst consensus data")
async def analyst_consensus(ticker: str):
try:
import yfinance as yf
t = yf.Ticker(ticker.upper())
info = t.info or {}
return {
"target_mean": _safe_float(info.get("targetMeanPrice"), None),
"target_high": _safe_float(info.get("targetHighPrice"), None),
"target_low": _safe_float(info.get("targetLowPrice"), None),
"target_median": _safe_float(info.get("targetMedianPrice"), None),
"recommendation": info.get("recommendationKey", "N/A"),
"num_analysts": info.get("numberOfAnalystOpinions", 0),
}
except Exception:
return {"target_mean": None, "target_high": None, "target_low": None, "target_median": None, "recommendation": "N/A", "num_analysts": 0}
@@ -0,0 +1,158 @@
"""Crypto price fetcher -- Bithumb (KRW) and Binance (USD) public APIs.
Provides standalone fetcher functions that can be used by the crypto router
or any other service that needs cryptocurrency price data.
"""
import time
from typing import Any, Dict, List, Optional
import requests
# ---------------------------------------------------------------------------
# Top 20 coins
# ---------------------------------------------------------------------------
TOP_20_COINS: List[str] = [
"BTC", "ETH", "BNB", "XRP", "SOL", "ADA", "DOGE", "AVAX", "DOT", "MATIC",
"LINK", "SHIB", "TRX", "UNI", "ATOM", "LTC", "ETC", "XLM", "NEAR", "APT",
]
# ---------------------------------------------------------------------------
# In-memory cache
# ---------------------------------------------------------------------------
_cache: Dict[str, Any] = {}
_cache_ts: Dict[str, float] = {}
_CACHE_TTL = 30 # seconds
def _get_cached(key: str) -> Optional[Any]:
if key in _cache and (time.time() - _cache_ts.get(key, 0)) < _CACHE_TTL:
return _cache[key]
return None
def _set_cached(key: str, value: Any) -> None:
_cache[key] = value
_cache_ts[key] = time.time()
# ---------------------------------------------------------------------------
# Bithumb (KRW)
# ---------------------------------------------------------------------------
def fetch_bithumb_all_krw(symbols: Optional[List[str]] = None) -> Dict[str, float]:
"""Fetch KRW prices from Bithumb ALL_KRW endpoint.
Uses the bulk endpoint (https://api.bithumb.com/public/ticker/ALL_KRW)
to avoid per-symbol rate limits.
Parameters
----------
symbols:
Coin symbols to include. Defaults to TOP_20_COINS.
Returns
-------
dict
Mapping of symbol -> KRW price (float).
"""
cached = _get_cached("bithumb_all_krw")
if cached is not None:
return cached
symbols = symbols or TOP_20_COINS
url = "https://api.bithumb.com/public/ticker/ALL_KRW"
prices: Dict[str, float] = {}
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
body = resp.json()
if body.get("status") != "0000":
return prices
data = body.get("data", {})
for sym in symbols:
coin_data = data.get(sym.upper())
if coin_data and isinstance(coin_data, dict):
closing = coin_data.get("closing_price")
if closing:
prices[sym.upper()] = float(closing)
except Exception:
pass
_set_cached("bithumb_all_krw", prices)
return prices
# ---------------------------------------------------------------------------
# Binance (USD)
# ---------------------------------------------------------------------------
def fetch_binance_prices(symbols: Optional[List[str]] = None) -> Dict[str, float]:
"""Fetch USD prices from Binance ticker/price endpoint.
Parameters
----------
symbols:
Coin symbols to include. Defaults to TOP_20_COINS.
Returns
-------
dict
Mapping of symbol -> USD price (float).
"""
cached = _get_cached("binance_prices")
if cached is not None:
return cached
symbols = symbols or TOP_20_COINS
url = "https://api.binance.com/api/v3/ticker/price"
prices: Dict[str, float] = {}
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
lookup = {item["symbol"]: float(item["price"]) for item in data}
for sym in symbols:
key = f"{sym.upper()}USDT"
if key in lookup:
prices[sym.upper()] = lookup[key]
except Exception:
pass
_set_cached("binance_prices", prices)
return prices
# ---------------------------------------------------------------------------
# Combined
# ---------------------------------------------------------------------------
def fetch_top20_prices(
symbols: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Return top-20 crypto prices with both KRW and USD.
Each entry contains:
- symbol: str
- price_usd: float | None
- price_krw: float | None
Parameters
----------
symbols:
Override default TOP_20_COINS list.
"""
symbols = symbols or TOP_20_COINS
usd = fetch_binance_prices(symbols)
krw = fetch_bithumb_all_krw(symbols)
results: List[Dict[str, Any]] = []
for sym in symbols:
results.append({
"symbol": sym.upper(),
"price_usd": usd.get(sym.upper()),
"price_krw": krw.get(sym.upper()),
})
return results
@@ -0,0 +1,247 @@
"""Discounted Cash Flow (DCF) valuation engine.
Implements multiple DCF model variants:
- Simple 5-year single-stage DCF
- 10-year two-stage DCF (growth fades from Stage 1 to terminal)
- Excel-style full DCF (EV -> Equity -> per-share value)
Also includes Damodaran sector WACC reference data and smart-default
assumption generation from CAPM beta and analyst growth estimates.
"""
from typing import Dict, List, Optional
from server.utils.safe_float import _safe_float
try:
from scipy.optimize import brentq
except ImportError:
brentq = None # type: ignore[assignment]
try:
import yfinance as yf
except ImportError:
yf = None # type: ignore[assignment]
# ---------------------------------------------------------------------------
# Damodaran sector WACC reference (approx. 2024/2025 baseline)
# ---------------------------------------------------------------------------
DAMODARAN_WACC: Dict[str, float] = {
"Software": 8.5,
"Retail": 7.5,
"Hardware": 9.0,
"Financials": 8.0,
"Healthcare": 7.2,
"Consumer": 7.5,
"Technology": 8.5,
"Industrial": 7.8,
"Energy": 8.2,
"Utilities": 6.5,
}
DAMODARAN_ERP_PCT: float = 4.6
"""US Equity Risk Premium (Damodaran estimate)."""
DAMODARAN_RF_PCT: float = 4.2
"""10-year risk-free rate (Damodaran estimate)."""
# ---------------------------------------------------------------------------
# DCF models
# ---------------------------------------------------------------------------
def dcf_intrinsic_value(
fcf: float,
wacc: float,
terminal_growth: float,
fcf_growth: float,
years: int = 5,
) -> float:
"""5-year single-stage DCF returning enterprise value.
Projects FCF at *fcf_growth* for *years* periods, then computes a
Gordon Growth terminal value discounted at *wacc*.
"""
if fcf is None or fcf <= 0:
return 0.0
if wacc <= terminal_growth or wacc <= 0:
return 0.0
pv = 0.0
fcft = float(fcf)
for t in range(1, years + 1):
pv += fcft / ((1 + wacc) ** t)
fcft *= (1 + fcf_growth)
terminal_fcf = fcft
tv = terminal_fcf * (1 + terminal_growth) / (wacc - terminal_growth)
pv += tv / ((1 + wacc) ** years)
return pv
def dcf_10y_2stage(
fcf: float,
wacc: float,
term_growth: float,
fcf_growth: float,
) -> float:
"""10-year two-stage DCF.
Stage 1 (Y1-5): FCF grows at *fcf_growth*.
Stage 2 (Y6-10): growth linearly fades to *term_growth*.
Terminal value at Y10 using Gordon Growth.
"""
if fcf is None or fcf <= 0:
return 0.0
if wacc <= term_growth or wacc <= 0:
return 0.0
pv = 0.0
fcft = float(fcf)
for t in range(1, 6):
pv += fcft / ((1 + wacc) ** t)
fcft *= (1 + fcf_growth)
for t in range(6, 11):
fade = (t - 6) / 4.0
g_t = fcf_growth + fade * (term_growth - fcf_growth)
fcft *= (1 + g_t)
pv += fcft / ((1 + wacc) ** t)
tv = fcft * (1 + term_growth) / (wacc - term_growth)
pv += tv / ((1 + wacc) ** 10)
return pv
def excel_style_dcf(
fcf_base: float,
wacc: float,
term_growth: float,
fcf_growth: float,
total_debt: float,
cash: float,
shares: float,
) -> Dict[str, Optional[float]]:
"""Full DCF: EV -> Equity Value -> Value per Share.
Returns
-------
dict
Keys: ``ev``, ``equity_value``, ``value_per_share``, ``shares``.
"""
ev = dcf_10y_2stage(fcf_base, wacc, term_growth, fcf_growth)
equity = ev - total_debt + cash
shares_safe = float(shares) if (shares is not None and float(shares) > 0) else None
value_per_share = (equity / shares_safe) if shares_safe else None
return {
"ev": ev,
"equity_value": equity,
"value_per_share": value_per_share,
"shares": shares_safe,
}
# ---------------------------------------------------------------------------
# WACC helpers
# ---------------------------------------------------------------------------
def reverse_dcf(
current_price: float,
shares: float,
total_debt: float,
cash: float,
wacc: float,
term_growth: float,
fcf_base: float,
projection_years: int = 10,
) -> Optional[float]:
"""Solve for the implied FCF growth rate that produces the current market price.
Uses Brent's root-finding method (scipy.optimize.brentq) to find the
growth rate *g* such that ``excel_style_dcf(..., g)["value_per_share"] == current_price``.
Returns
-------
float | None
Implied annual FCF growth rate (decimal), or None if no solution is found.
"""
if brentq is None:
return None
if shares <= 0 or current_price <= 0 or wacc <= term_growth:
return None
def _objective(g: float) -> float:
result = excel_style_dcf(fcf_base, wacc, term_growth, g, total_debt, cash, shares)
vps = result.get("value_per_share")
if vps is None:
return -current_price
return vps - current_price
try:
implied_growth = brentq(_objective, -0.50, 1.00, xtol=1e-6, maxiter=200)
return round(implied_growth, 6)
except (ValueError, RuntimeError):
return None
def _damodaran_wacc_for_sector(sector: str) -> float:
"""Map a yfinance sector string to closest Damodaran WACC (default 8.0%)."""
if not sector:
return 8.0
s = (sector or "").lower()
if "software" in s or "technology" in s or "internet" in s:
return DAMODARAN_WACC.get("Software", 8.5)
if "hardware" in s or "semiconductor" in s:
return DAMODARAN_WACC.get("Hardware", 9.0)
if "retail" in s or "consumer" in s or "cyclical" in s:
return DAMODARAN_WACC.get("Retail", 7.5)
if "financial" in s or "bank" in s or "insurance" in s:
return DAMODARAN_WACC.get("Financials", 8.0)
if "health" in s or "pharma" in s:
return DAMODARAN_WACC.get("Healthcare", 7.2)
if "industrial" in s:
return DAMODARAN_WACC.get("Industrial", 7.8)
if "energy" in s or "oil" in s:
return DAMODARAN_WACC.get("Energy", 8.2)
if "utilities" in s:
return DAMODARAN_WACC.get("Utilities", 6.5)
return 8.0
# ---------------------------------------------------------------------------
# Smart defaults
# ---------------------------------------------------------------------------
def get_dcf_smart_defaults(ticker: str) -> Dict[str, float]:
"""Auto-generate WACC, Terminal Growth, and FCF Growth from CAPM beta and analyst estimates.
Returns
-------
dict
Keys: ``wacc_pct``, ``term_growth_pct``, ``fcf_growth_pct``.
"""
out: Dict[str, float] = {"wacc_pct": 10.0, "term_growth_pct": 2.5, "fcf_growth_pct": 8.0}
if not yf or not ticker:
return out
try:
t = yf.Ticker(ticker.upper())
info = t.info or {}
beta = info.get("beta")
if beta is None:
beta = 1.0
else:
try:
beta = float(beta)
except (TypeError, ValueError):
beta = 1.0
risk_free = 4.0
market_risk_premium = 5.0
calculated_wacc = risk_free + (beta * market_risk_premium)
out["wacc_pct"] = round(min(20.0, max(4.0, calculated_wacc)), 1)
out["term_growth_pct"] = 2.5
rev_growth = info.get("revenueGrowth") or info.get("earningsGrowth")
if rev_growth is not None:
try:
g = float(rev_growth)
out["fcf_growth_pct"] = round(min(30.0, max(-10.0, g * 100)), 1)
except (TypeError, ValueError):
pass
return out
except Exception:
return out
@@ -0,0 +1,188 @@
"""Financial health metrics: DuPont, Altman Z, Piotroski F-Score, radar, and sector-specific.
All functions return pure data (dicts, DataFrames) with no presentation logic.
Consumers (API routers, Streamlit UI) handle display and charting.
"""
from typing import Any, Dict, List, Optional, Tuple
import pandas as pd
from server.utils.safe_float import _safe_float
from server.services.market_fetcher import (
_get_annual_financials_balance_cashflow,
_get_row_series,
)
try:
import yfinance as yf
except ImportError:
yf = None # type: ignore[assignment]
# ---------------------------------------------------------------------------
# Radar normalisation
# ---------------------------------------------------------------------------
def _radar_norm(
roe_pct: Optional[float],
current_ratio: Optional[float],
asset_turnover: Optional[float],
equity_mult: Optional[float],
rev_yoy_pct: Optional[float],
) -> List[float]:
"""Normalise five raw metrics to 0-100 for radar chart display."""
def n_roe(x: Optional[float]) -> float:
return min(100, max(0, (x + 10) / 40 * 100)) if x is not None else 50
def n_cr(x: Optional[float]) -> float:
return min(100, max(0, x / 3 * 100)) if x is not None else 50
def n_at(x: Optional[float]) -> float:
return min(100, max(0, x * 50)) if x is not None else 50
def n_em(x: Optional[float]) -> float:
return min(100, max(0, (x - 0.5) / 2.5 * 100)) if x is not None else 50
def n_yoy(x: Optional[float]) -> float:
return min(100, max(0, (x + 20) / 50 * 100)) if x is not None else 50
return [n_roe(roe_pct), n_cr(current_ratio), n_at(asset_turnover), n_em(equity_mult), n_yoy(rev_yoy_pct)]
# ---------------------------------------------------------------------------
# DuPont / Altman Z / Red Flags / YoY
# ---------------------------------------------------------------------------
def get_dupont_altman_redflags_yoy(ticker: str) -> Dict[str, Any]:
"""DuPont 3-step ROE, Altman Z-Score, red flags, and YoY ratio changes.
Returns
-------
dict
Keys: ``dupont`` (DataFrame), ``yoy`` (list), ``altman_z`` (float|None),
``red_flags`` (list of dicts).
"""
try:
fin, bal, _ = _get_annual_financials_balance_cashflow(ticker)
if fin is None or fin.empty or bal is None or bal.empty:
return {}
t = yf.Ticker(ticker.upper())
info = t.info or {}
col_list = fin.columns.tolist()
if col_list and str(col_list[0]).startswith("TTM"):
dates = col_list[:3]
else:
dates = sorted(col_list, reverse=True)[:3]
if not dates:
return {}
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
ebit = _get_row_series(fin, "Operating Income", "EBIT")
gross = _get_row_series(fin, "Gross Profit")
interest = _get_row_series(fin, "Interest Expense", "Interest Expense Net")
total_assets = _get_row_series(bal, "Total Assets")
total_equity = _get_row_series(bal, "Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest")
current_assets = _get_row_series(bal, "Current Assets")
current_liab = _get_row_series(bal, "Current Liabilities")
retained = _get_row_series(bal, "Retained Earnings")
total_liab = _get_row_series(bal, "Total Liabilities")
market_cap = info.get("marketCap") or info.get("Market Cap")
def _v(s: Optional[pd.Series], d: Any) -> Optional[float]:
if s is None or d not in s.index:
return None
return _safe_float(s.get(d))
rows: List[Dict[str, Any]] = []
for i, d in enumerate(dates):
yr = int(str(d)[:4]) if (isinstance(d, str) and str(d)[:4].isdigit()) else (d.year if hasattr(d, "year") else (2024 - i))
r = _v(rev, d)
net_i = _v(ni, d)
ta = _v(total_assets, d)
te = _v(total_equity, d)
if ta and ta > 0 and te and te > 0 and r and r != 0:
npm = (net_i / r * 100) if net_i is not None else None
at = r / ta
em = ta / te
roe = (net_i / te * 100) if net_i else None
else:
npm = at = em = roe = None
gross_p = _v(gross, d)
gross_margin = (gross_p / r * 100) if (gross_p and r and r != 0) else None
op_inc = _v(ebit, d)
op_margin = (op_inc / r * 100) if (op_inc and r and r != 0) else None
ca = _v(current_assets, d)
cl = _v(current_liab, d)
current_ratio = (ca / cl) if (ca and cl and cl != 0) else None
int_exp = _v(interest, d)
interest_cov: Optional[float] = None
if op_inc is not None and int_exp is not None and int_exp != 0:
_ic = op_inc / int_exp
interest_cov = round(_ic, 2) if (_ic == _ic and not pd.isna(_ic)) else None
rows.append({
"Year": yr, "Revenue": r, "Net Income": net_i,
"NPM %": round(npm, 2) if npm is not None else None,
"Asset Turnover": round(at, 4) if at is not None else None,
"Equity Mult.": round(em, 2) if em is not None else None,
"ROE %": round(roe, 2) if roe is not None else None,
"Gross Margin %": round(gross_margin, 2) if gross_margin is not None else None,
"Operating Margin %": round(op_margin, 2) if op_margin is not None else None,
"Current Ratio": round(current_ratio, 2) if current_ratio is not None else None,
"Interest Coverage": interest_cov,
})
dupont_df = pd.DataFrame(rows)
# YoY
yoy: List[Dict[str, Any]] = []
if len(dupont_df) >= 2:
for col in ["NPM %", "ROE %", "Gross Margin %", "Operating Margin %", "Current Ratio", "Interest Coverage"]:
if col not in dupont_df.columns:
continue
cur = dupont_df[col].iloc[0]
prev = dupont_df[col].iloc[1]
if cur is None or prev is None or prev == 0 or pd.isna(cur) or pd.isna(prev):
continue
if "Margin" in col or "NPM" in col or "ROE" in col:
chg_pp = cur - prev
if pd.isna(chg_pp):
continue
yoy.append({"Ratio": col, "Latest": cur, "Prior": prev, "YoY (pp)": round(chg_pp, 2),
"Comment": f"{'Improved' if chg_pp > 0 else 'Declined'} by {abs(chg_pp):.1f}% YoY"})
else:
pct = (cur - prev) / abs(prev) * 100
if pd.isna(pct):
continue
yoy.append({"Ratio": col, "Latest": cur, "Prior": prev, "YoY %": round(pct, 1),
"Comment": f"{'Up' if pct > 0 else 'Down'} {abs(round(pct, 1))}% YoY"})
# Altman Z
latest_bal_d = bal.columns[0]
wc = (_v(current_assets, latest_bal_d) or 0) - (_v(current_liab, latest_bal_d) or 0)
ta_l = _v(total_assets, latest_bal_d)
re_l = _v(retained, latest_bal_d)
tl_l = _v(total_liab, latest_bal_d)
ebit_l = _v(ebit, fin.columns[0])
sales_l = _v(rev, fin.columns[0])
altman_z: Optional[float] = None
if ta_l and ta_l > 0 and market_cap is not None and tl_l and tl_l != 0 and sales_l:
a = wc / ta_l
b = (re_l or 0) / ta_l
c = (ebit_l or 0) / ta_l
dd = market_cap / tl_l
e = sales_l / ta_l
altman_z = 1.2 * a + 1.4 * b + 3.3 * c + 0.6 * dd + 1.0 * e
# Red flags
red_flags: List[Dict[str, Any]] = []
if len(dupont_df) > 0:
row0 = dupont_df.iloc[0]
cr = row0.get("Current Ratio")
if cr is not None and cr < 1.0:
red_flags.append({"metric": "Current Ratio", "value": cr, "threshold": 1.0, "flag": "WARNING",
"comment": "Current assets do not cover current liabilities; liquidity risk."})
ic = row0.get("Interest Coverage")
if ic is not None and ic < 1.5:
red_flags.append({"metric": "Interest Coverage", "value": ic, "threshold": 1.5, "flag": "WARNING",
"comment": "EBIT barely covers interest; default risk."})
return {"dupont": dupont_df, "yoy": yoy, "altman_z": round(altman_z, 2) if altman_z is not None else None, "red_flags": red_flags}
except Exception:
return {}
@@ -0,0 +1,411 @@
"""Extended financial metrics: Piotroski F-Score, Sankey, radar, sector-specific, quarterly.
Complements :mod:`server.services.financial_metrics` with scoring models,
income-statement flow data, and quarterly momentum indicators.
"""
from typing import Any, Dict, List, Optional
import pandas as pd
from server.utils.safe_float import _safe_float
from server.services.market_fetcher import (
_get_annual_financials_balance_cashflow,
_get_row_series,
)
from server.services.financial_metrics import (
_radar_norm,
get_dupont_altman_redflags_yoy,
)
try:
import yfinance as yf
except ImportError:
yf = None # type: ignore[assignment]
# ---------------------------------------------------------------------------
# Income Statement Sankey
# ---------------------------------------------------------------------------
def get_income_statement_sankey_data(ticker: str) -> Dict[str, float]:
"""Revenue -> COGS -> Gross Profit -> OpEx -> OpIncome -> Net Income."""
out: Dict[str, float] = {"revenue": 0, "cogs": 0, "gross_profit": 0, "opex": 0, "operating_income": 0, "tax_interest_other": 0, "net_income": 0}
fin, _, _ = _get_annual_financials_balance_cashflow(ticker)
if fin is None or fin.empty:
return out
try:
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
cogs = _get_row_series(fin, "Cost Of Revenue", "Cost Of Goods Sold")
gross = _get_row_series(fin, "Gross Profit")
op_inc = _get_row_series(fin, "Operating Income", "EBIT")
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
if rev is None or len(rev) == 0:
return out
d = rev.index[0]
revenue = abs(_safe_float(rev.get(d)) or 0)
cogs_val = abs(_safe_float(cogs.get(d)) if cogs is not None and d in cogs.index else 0) or 0
gross_val = _safe_float(gross.get(d)) if gross is not None and d in gross.index else None
if gross_val is None:
gross_val = (revenue - cogs_val) if revenue and cogs_val is not None else revenue
gross_val = abs(gross_val) if gross_val is not None else 0
op_inc_val = _safe_float(op_inc.get(d)) if op_inc is not None and d in op_inc.index else 0
ni_val = _safe_float(ni.get(d)) if ni is not None and d in ni.index else 0
opex_val = max(0, gross_val - op_inc_val) if gross_val >= op_inc_val else 0
tax_interest_other = max(0, op_inc_val - ni_val) if (op_inc_val - ni_val) > 0 else abs(min(0, op_inc_val - ni_val))
return {"revenue": max(revenue, 1), "cogs": min(cogs_val, revenue - 1e-6), "gross_profit": gross_val,
"opex": opex_val, "operating_income": op_inc_val, "tax_interest_other": tax_interest_other, "net_income": ni_val}
except Exception:
return out
def sankey_data_from_ai(ai_dict: Dict[str, Any]) -> Dict[str, float]:
"""Build Sankey input from ``get_sec_financials_llm`` result."""
cur = (ai_dict or {}).get("current_yr") or {}
revenue = max(0, (cur.get("Revenue") or 0))
cogs = max(0, min(cur.get("CostOfRevenue") or 0, revenue - 1e-6))
gross_profit = revenue - cogs
opex = max(0, cur.get("OperatingExpenses") or 0)
operating_income = gross_profit - opex
net_income = cur.get("NetIncome") or 0
tax_interest_other = max(0, operating_income - net_income) if operating_income > net_income else abs(min(0, operating_income - net_income))
return {"revenue": max(revenue, 1), "cogs": cogs, "gross_profit": gross_profit, "opex": opex,
"operating_income": operating_income, "tax_interest_other": tax_interest_other, "net_income": net_income}
# ---------------------------------------------------------------------------
# Piotroski F-Score
# ---------------------------------------------------------------------------
def piotroski_from_ai(ai_dict: Dict[str, Any]) -> Dict[str, Any]:
"""Piotroski F-Score (0-9) from AI-extracted current/previous year."""
out: Dict[str, Any] = {"score": 0, "criteria": [], "used_ttm": True}
cur = (ai_dict or {}).get("current_yr") or {}
prev = (ai_dict or {}).get("previous_yr") or {}
if not cur:
return out
def v(d: dict, k: str) -> float:
return d.get(k) or 0
ni0, ni1 = v(cur, "NetIncome"), v(prev, "NetIncome")
ocf0 = v(cur, "OperatingCashFlow")
ta0, ta1 = v(cur, "TotalAssets"), v(prev, "TotalAssets")
roa0 = (ni0 / ta0 * 100) if ta0 and ta0 != 0 else None
roa1 = (ni1 / ta1 * 100) if ta1 and ta1 != 0 else None
lt0, lt1 = v(cur, "LongTermDebt"), v(prev, "LongTermDebt")
ca0, cl0 = v(cur, "CurrentAssets"), v(cur, "CurrentLiabilities")
ca1, cl1 = v(prev, "CurrentAssets"), v(prev, "CurrentLiabilities")
cr0 = (ca0 / cl0) if cl0 and cl0 != 0 else None
cr1 = (ca1 / cl1) if cl1 and cl1 != 0 else None
sh0, sh1 = v(cur, "SharesOutstanding"), v(prev, "SharesOutstanding")
rev0, rev1 = v(cur, "Revenue"), v(prev, "Revenue")
gm0 = ((rev0 - v(cur, "CostOfRevenue")) / rev0 * 100) if rev0 and rev0 != 0 else None
gm1 = ((rev1 - v(prev, "CostOfRevenue")) / rev1 * 100) if rev1 and rev1 != 0 else None
at0 = (rev0 / ta0) if rev0 and ta0 and ta0 != 0 else None
at1 = (rev1 / ta1) if rev1 and ta1 and ta1 != 0 else None
criteria: List[tuple] = [
("Net Income > 0 (profitability)", ni0 > 0),
("Operating Cash Flow > 0 (cash generative)", ocf0 > 0),
("ROA increased vs prior period (improving returns)", roa0 is not None and roa1 is not None and roa0 > roa1),
("OCF > Net Income (earnings quality, less accruals)", ocf0 > ni0),
("Leverage decreased: LT Debt/Assets lower (less debt)", ta0 and ta1 and (lt0 / ta0) < (lt1 / ta1) if ta0 and ta1 else False),
("Current Ratio improved (better liquidity)", cr0 is not None and cr1 is not None and cr0 > cr1),
("No dilution: shares unchanged or lower (no equity raise)", (sh0 <= sh1) if (sh0 and sh1) else True),
("Gross Margin improved (pricing power)", gm0 is not None and gm1 is not None and gm0 > gm1),
("Asset Turnover improved (efficiency)", at0 is not None and at1 is not None and at0 > at1),
]
out["score"] = sum(1 for _, p in criteria if p)
out["criteria"] = criteria
return out
def get_piotroski_fscore(ticker: str) -> Dict[str, Any]:
"""Piotroski F-Score from yahooquery/yfinance data."""
out: Dict[str, Any] = {"score": 0, "criteria": [], "used_ttm": False}
fin, bal, cf = _get_annual_financials_balance_cashflow(ticker)
if fin is None or fin.empty or bal is None or bal.empty:
return out
if cf is None or cf.empty:
cf = pd.DataFrame()
try:
ncol = min(2, len(fin.columns))
rev = _get_row_series(fin, "Total Revenue", "Revenue")
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
gross = _get_row_series(fin, "Gross Profit")
ta = _get_row_series(bal, "Total Assets")
lt_debt = _get_row_series(bal, "Long Term Debt")
ca = _get_row_series(bal, "Current Assets")
cl = _get_row_series(bal, "Current Liabilities")
ocf = _get_row_series(cf, "Operating Cash Flow", "Cash From Operating Activities") if not cf.empty else None
shares = _get_row_series(bal, "Share Issued") or _get_row_series(bal, "Ordinary Shares Number")
if shares is None and yf:
ti = yf.Ticker(ticker.upper())
info = getattr(ti, "info", None) or {}
sh_info = info.get("sharesOutstanding") or info.get("Shares Outstanding")
if sh_info is not None:
try:
shares = pd.Series([float(sh_info)] * ncol, index=fin.columns[:ncol])
except (TypeError, ValueError):
pass
def v0(s: Optional[pd.Series]) -> Optional[float]:
if s is None or len(s) == 0:
return None
x = _safe_float(s.iloc[0])
return x if (x is not None and x == x and not pd.isna(x)) else None
def v1(s: Optional[pd.Series]) -> Optional[float]:
if s is None or len(s) < 2:
return None
x = _safe_float(s.iloc[1])
return x if (x is not None and x == x and not pd.isna(x)) else None
ni0, ni1 = v0(ni), v1(ni)
ocf0 = v0(ocf) if ocf is not None else None
ta0, ta1 = v0(ta), v1(ta)
roa0 = (ni0 / ta0 * 100) if (ni0 is not None and ta0 and ta0 != 0) else None
roa1 = (ni1 / ta1 * 100) if (ni1 is not None and ta1 and ta1 != 0) else None
lt0 = v0(lt_debt) or 0
lt1 = v1(lt_debt) or 0
cl0, cl1 = v0(cl), v1(cl)
ca0, ca1 = v0(ca), v1(ca)
cr0 = (ca0 / cl0) if (ca0 is not None and cl0 and cl0 != 0) else None
cr1 = (ca1 / cl1) if (ca1 is not None and cl1 and cl1 != 0) else None
sh0, sh1 = v0(shares), v1(shares)
rev0, rev1 = v0(rev), v1(rev)
gm0 = (v0(gross) / rev0 * 100) if (gross is not None and rev0 and rev0 != 0) else None
gm1 = (v1(gross) / rev1 * 100) if (gross is not None and rev1 and rev1 != 0) else None
at0 = (rev0 / ta0) if (rev0 and ta0 and ta0 != 0) else None
at1 = (rev1 / ta1) if (rev1 and ta1 and ta1 != 0) else None
criteria = [
("Net Income > 0 (profitability)", ni0 is not None and ni0 > 0),
("Operating Cash Flow > 0 (cash generative)", ocf0 is not None and ocf0 > 0),
("ROA increased vs prior period (improving returns)", roa0 is not None and roa1 is not None and roa0 > roa1),
("OCF > Net Income (earnings quality, less accruals)", ocf0 is not None and ni0 is not None and ocf0 > ni0),
("Leverage decreased: LT Debt/Assets lower (less debt)", ta0 and ta0 != 0 and ta1 and ta1 != 0 and (lt0 / ta0) < (lt1 / ta1)),
("Current Ratio improved (better liquidity)", cr0 is not None and cr1 is not None and cr0 > cr1),
("No dilution: shares unchanged or lower (no equity raise)", (sh0 is not None and sh1 is not None and sh0 <= sh1) if (sh0 is not None and sh1 is not None) else True),
("Gross Margin improved (pricing power)", gm0 is not None and gm1 is not None and gm0 > gm1),
("Asset Turnover improved (efficiency)", at0 is not None and at1 is not None and at0 > at1),
]
out["score"] = sum(1 for _, p in criteria if p)
out["criteria"] = criteria
out["used_ttm"] = bool(any(str(c).startswith("TTM") for c in fin.columns))
return out
except Exception:
return out
# ---------------------------------------------------------------------------
# Radar metrics
# ---------------------------------------------------------------------------
def radar_metrics_from_ai(ai_dict: Dict[str, Any]) -> Dict[str, Any]:
"""Build radar chart data from AI-extracted financials."""
cur = (ai_dict or {}).get("current_yr") or {}
prev = (ai_dict or {}).get("previous_yr") or {}
if not cur:
return {}
eq0 = (cur.get("TotalAssets") or 0) - (cur.get("CurrentLiabilities") or 0) - (cur.get("LongTermDebt") or 0)
if eq0 <= 0:
eq0 = (cur.get("TotalAssets") or 0) * 0.5
roe = (cur.get("NetIncome") or 0) / eq0 * 100 if eq0 else 0
ca, cl = cur.get("CurrentAssets") or 0, cur.get("CurrentLiabilities") or 0
current_ratio = (ca / cl) if cl and cl != 0 else 0
ta = cur.get("TotalAssets") or 1
asset_turnover = (cur.get("Revenue") or 0) / ta
equity_mult = (cur.get("TotalAssets") or 0) / eq0 if eq0 else 0
rev0, rev1 = cur.get("Revenue") or 0, prev.get("Revenue") or 0
rev_yoy = ((rev0 - rev1) / rev1 * 100) if rev1 and rev1 != 0 else 0
theta = ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"]
return {"theta": theta, "r": _radar_norm(roe, current_ratio, asset_turnover, equity_mult, rev_yoy), "labels": theta}
def get_radar_metrics_normalized(ticker: str) -> Dict[str, Any]:
"""ROE, Current Ratio, Asset Turnover, Equity Mult, Revenue YoY normalised 0-100."""
if not ticker:
return {}
q = get_dupont_altman_redflags_yoy(ticker)
if not q:
return {}
dupont_df = q.get("dupont")
if dupont_df is None or dupont_df.empty or len(dupont_df) < 2:
return {}
row0 = dupont_df.iloc[0]
roe = row0.get("ROE %") or 0
cr = row0.get("Current Ratio") or 0
at = row0.get("Asset Turnover") or 0
em = row0.get("Equity Mult.") or 0
rev0 = dupont_df["Revenue"].iloc[0] if "Revenue" in dupont_df.columns else None
rev1 = dupont_df["Revenue"].iloc[1] if "Revenue" in dupont_df.columns else None
rev_yoy = ((rev0 - rev1) / rev1 * 100) if (rev0 and rev1 and rev1 != 0) else 0
theta = ["Profitability (ROE)", "Liquidity (Curr.Ratio)", "Efficiency (Asset Turn.)", "Solvency (Equity Mult.)", "Growth (Rev YoY)"]
return {"theta": theta, "r": _radar_norm(roe, cr, at, em, rev_yoy), "labels": theta}
# ---------------------------------------------------------------------------
# Sector-specific metrics
# ---------------------------------------------------------------------------
def get_sector_specific_metrics(ticker: str, sector: str) -> Dict[str, Any]:
"""Technology: Rule of 40, R&D %. Retail: Inventory Turnover. Financials: ROE/ROA."""
if not yf:
return {}
try:
t = yf.Ticker(ticker.upper())
fin = t.financials
bal = t.balance_sheet
if fin is None or fin.empty:
fin = getattr(t, "quarterly_financials", None)
if fin is not None and not fin.empty:
fin = fin.iloc[:, :4].sum(axis=1).to_frame()
if bal is None or bal.empty:
bal = getattr(t, "quarterly_balance_sheet", None)
out: Dict[str, Any] = {}
sector_lower = (sector or "").lower()
if "technology" in sector_lower or "software" in sector_lower or "tech" in sector_lower:
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
cf_source = t.cashflow or getattr(t, "quarterly_cashflow", None)
ocf = _get_row_series(cf_source, "Operating Cash Flow", "Cash From Operating Activities")
capx = _get_row_series(cf_source, "Capital Expenditure", "Capital Expenditures")
rd = _get_row_series(fin, "Research And Development", "Research And Development Expense")
if rev is not None and len(rev) > 0:
r0 = _safe_float(rev.iloc[0])
if ocf is not None and len(ocf) > 0 and capx is not None and len(capx) > 0:
fcf = _safe_float(ocf.iloc[0]) - _safe_float(capx.iloc[0])
out["FCF Margin %"] = round(fcf / r0 * 100, 2) if r0 and fcf is not None else None
if rd is not None and len(rd) > 0:
out["R&D % of Revenue"] = round(_safe_float(rd.iloc[0]) / r0 * 100, 2) if r0 else None
if len(rev) >= 2:
cur_r, prev_r = _safe_float(rev.iloc[0]), _safe_float(rev.iloc[1])
rev_growth = ((cur_r - prev_r) / prev_r * 100) if prev_r and prev_r != 0 else None
if rev_growth is not None and "FCF Margin %" in out and out["FCF Margin %"] is not None:
out["Rule of 40 (Rev Growth + FCF Margin)"] = round(rev_growth + out["FCF Margin %"], 1)
if "consumer" in sector_lower or "retail" in sector_lower or "cyclical" in sector_lower:
inv = _get_row_series(bal, "Inventory", "Total Inventory")
cogs = _get_row_series(fin, "Cost Of Revenue", "Cost Of Goods Sold")
rev = _get_row_series(fin, "Total Revenue", "Revenue", "Net Revenue")
op_inc = _get_row_series(fin, "Operating Income", "EBIT")
if inv is not None and len(inv) > 0 and cogs is not None and len(cogs) > 0:
out["Inventory Turnover"] = round(_safe_float(cogs.iloc[0]) / _safe_float(inv.iloc[0]), 2) if _safe_float(inv.iloc[0]) else None
if rev is not None and len(rev) > 0 and op_inc is not None and len(op_inc) > 0:
out["Operating Margin %"] = round(_safe_float(op_inc.iloc[0]) / _safe_float(rev.iloc[0]) * 100, 2) if _safe_float(rev.iloc[0]) else None
if "financial" in sector_lower or "bank" in sector_lower or "insurance" in sector_lower:
ni = _get_row_series(fin, "Net Income", "Net Income Common Stockholders")
te = _get_row_series(bal, "Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest")
ta_s = _get_row_series(bal, "Total Assets")
if ni is not None and te is not None and len(ni) > 0 and len(te) > 0:
out["ROE %"] = round(_safe_float(ni.iloc[0]) / _safe_float(te.iloc[0]) * 100, 2) if _safe_float(te.iloc[0]) else None
if ni is not None and ta_s is not None and len(ni) > 0 and len(ta_s) > 0:
out["ROA %"] = round(_safe_float(ni.iloc[0]) / _safe_float(ta_s.iloc[0]) * 100, 2) if _safe_float(ta_s.iloc[0]) else None
return out
except Exception:
return {}
# ---------------------------------------------------------------------------
# Quarterly momentum
# ---------------------------------------------------------------------------
def get_quarterly_momentum(ticker: str) -> Dict[str, Any]:
"""Last 4 quarters Revenue/NI with QoQ growth for the most recent."""
out: Dict[str, Any] = {"df": None, "qoq_revenue_pct": None, "qoq_ni_pct": None}
if not yf or not ticker:
return out
try:
t = yf.Ticker(ticker.upper())
qfin = getattr(t, "quarterly_financials", None)
if qfin is None or qfin.empty or len(qfin.columns) < 2:
return out
rev = _get_row_series(qfin, "Total Revenue", "Revenue", "Net Revenue")
ni = _get_row_series(qfin, "Net Income", "Net Income Common Stockholders")
if rev is None and ni is None:
return out
cols = list(qfin.columns)[:4]
rows: List[Dict[str, Any]] = []
for c in cols:
try:
if hasattr(c, "strftime"):
q = (c.month - 1) // 3 + 1
label = c.strftime("%Y") + f"-Q{q}"
else:
label = str(c)[:12]
except Exception:
label = str(c)[:12]
r_val = _safe_float(rev.loc[c]) if rev is not None and c in rev.index else None
n_val = _safe_float(ni.loc[c]) if ni is not None and c in ni.index else None
rows.append({"Quarter": label, "Revenue": r_val, "Net Income": n_val})
out["df"] = pd.DataFrame(rows)
if len(rows) >= 2:
r0, r1 = rows[0].get("Revenue"), rows[1].get("Revenue")
n0, n1 = rows[0].get("Net Income"), rows[1].get("Net Income")
if r0 is not None and r1 is not None and r1 != 0:
out["qoq_revenue_pct"] = round((r0 - r1) / abs(r1) * 100, 1)
if n0 is not None and n1 is not None and n1 != 0:
out["qoq_ni_pct"] = round((n0 - n1) / abs(n1) * 100, 1)
return out
except Exception:
return out
def get_quarterly_ratio_changes(ticker: str) -> List[Dict[str, Any]]:
"""QoQ ratio changes for NPM, ROE, Gross/Operating Margin, Current Ratio, Interest Coverage."""
out: List[Dict[str, Any]] = []
if not yf or not ticker:
return out
try:
t = yf.Ticker(ticker.upper())
qf = getattr(t, "quarterly_financials", None)
qb = getattr(t, "quarterly_balance_sheet", None)
if qf is None or qf.empty or qb is None or qb.empty or len(qf.columns) < 2 or len(qb.columns) < 2:
return out
rev = _get_row_series(qf, "Total Revenue", "Revenue", "Net Revenue")
ni = _get_row_series(qf, "Net Income", "Net Income Common Stockholders")
gross = _get_row_series(qf, "Gross Profit")
ebit = _get_row_series(qf, "Operating Income", "EBIT")
interest = _get_row_series(qf, "Interest Expense", "Interest Expense Net")
ta = _get_row_series(qb, "Total Assets")
te = _get_row_series(qb, "Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest")
ca = _get_row_series(qb, "Current Assets")
cl = _get_row_series(qb, "Current Liabilities")
def v(s: Optional[pd.Series], col: Any) -> Optional[float]:
if s is None or col not in s.index:
return None
return _safe_float(s.get(col))
c0, c1 = qf.columns[0], qf.columns[1]
b0, b1 = qb.columns[0], qb.columns[1]
r0, r1 = v(rev, c0), v(rev, c1)
n0, n1 = v(ni, c0), v(ni, c1)
g0, g1 = v(gross, c0), v(gross, c1)
e0, e1 = v(ebit, c0), v(ebit, c1)
i0, i1 = v(interest, c0), v(interest, c1)
te0, te1 = v(te, b0), v(te, b1)
ca0, ca1 = v(ca, b0), v(ca, b1)
cl0, cl1 = v(cl, b0), v(cl, b1)
npm0 = (n0 / r0 * 100) if (n0 is not None and r0 and r0 != 0) else None
npm1 = (n1 / r1 * 100) if (n1 is not None and r1 and r1 != 0) else None
roe0 = (n0 / te0 * 100) if (n0 is not None and te0 and te0 != 0) else None
roe1 = (n1 / te1 * 100) if (n1 is not None and te1 and te1 != 0) else None
gm0 = (g0 / r0 * 100) if (g0 is not None and r0 and r0 != 0) else None
gm1 = (g1 / r1 * 100) if (g1 is not None and r1 and r1 != 0) else None
om0 = (e0 / r0 * 100) if (e0 is not None and r0 and r0 != 0) else None
om1 = (e1 / r1 * 100) if (e1 is not None and r1 and r1 != 0) else None
cr0 = (ca0 / cl0) if (ca0 is not None and cl0 and cl0 != 0) else None
cr1 = (ca1 / cl1) if (ca1 is not None and cl1 and cl1 != 0) else None
ic0 = (e0 / i0) if (e0 is not None and i0 and i0 != 0) else None
ic1 = (e1 / i1) if (e1 is not None and i1 and i1 != 0) else None
def make_row(metric: str, cur: Optional[float], prev: Optional[float], is_pct_point: bool = False) -> Optional[Dict[str, Any]]:
if cur is None:
return None
if prev is None:
return {"Metric": metric, "Current Value": round(cur, 2), "Change": "-", "Trend": "-"}
chg = (cur - prev) if is_pct_point else (((cur - prev) / abs(prev) * 100) if prev != 0 else 0)
trend = "up" if chg > 0 else ("down" if chg < 0 else "flat")
chg_str = f"{chg:+.1f}%" if not is_pct_point else f"{chg:+.1f} pp"
return {"Metric": metric, "Current Value": round(cur, 2), "Change": chg_str, "Trend": trend}
for name, cur_v, prev_v, is_pp in [
("NPM %", npm0, npm1, True), ("ROE %", roe0, roe1, True), ("Gross Margin %", gm0, gm1, True),
("Operating Margin %", om0, om1, True), ("Current Ratio", cr0, cr1, False), ("Interest Coverage", ic0, ic1, False),
]:
r = make_row(name, cur_v, prev_v, is_pp)
if r:
out.append(r)
return out
except Exception:
return out
@@ -0,0 +1,151 @@
"""FX rate fetcher -- yfinance-based with in-memory TTL cache.
Provides current rates and 1-year history for major currency pairs.
"""
import time
from typing import Any, Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# In-memory cache with configurable TTL
# ---------------------------------------------------------------------------
_cache: Dict[str, Any] = {}
_cache_ts: Dict[str, float] = {}
_CACHE_TTL = 60 # seconds
# Default pairs
DEFAULT_PAIRS: List[str] = ["USDKRW=X", "GBPUSD=X", "EURUSD=X", "USDJPY=X"]
def _get_cached(key: str) -> Optional[Any]:
if key in _cache and (time.time() - _cache_ts.get(key, 0)) < _CACHE_TTL:
return _cache[key]
return None
def _set_cached(key: str, value: Any) -> None:
_cache[key] = value
_cache_ts[key] = time.time()
def _normalise_pair(pair: str) -> str:
"""Ensure pair is in Yahoo Finance format (e.g. 'USDKRW=X')."""
p = pair.upper().replace("/", "").strip()
if not p.endswith("=X"):
p = f"{p}=X"
return p
# ---------------------------------------------------------------------------
# Current rates
# ---------------------------------------------------------------------------
def fetch_fx_rate(pair: str) -> Optional[float]:
"""Fetch the latest exchange rate for a single currency pair.
Parameters
----------
pair:
Currency pair string, e.g. ``"USDKRW"``, ``"USDKRW=X"``, ``"EUR/USD"``.
Returns
-------
float or None
The latest rate, or None if unavailable.
"""
symbol = _normalise_pair(pair)
cache_key = f"fx_rate:{symbol}"
cached = _get_cached(cache_key)
if cached is not None:
return cached
try:
import yfinance as yf
ticker = yf.Ticker(symbol)
fast = getattr(ticker, "fast_info", None)
if fast:
price = getattr(fast, "last_price", None)
if price and float(price) > 0:
rate = float(price)
_set_cached(cache_key, rate)
return rate
hist = ticker.history(period="1d")
if hist is not None and not hist.empty:
rate = float(hist["Close"].iloc[-1])
_set_cached(cache_key, rate)
return rate
except Exception:
pass
return None
def fetch_multiple_rates(
pairs: Optional[List[str]] = None,
) -> Dict[str, float]:
"""Fetch current rates for multiple pairs.
Parameters
----------
pairs:
List of pair strings. Defaults to DEFAULT_PAIRS.
Returns
-------
dict
Mapping of normalised pair symbol -> rate.
"""
pairs = pairs or DEFAULT_PAIRS
rates: Dict[str, float] = {}
for pair in pairs:
rate = fetch_fx_rate(pair)
if rate is not None:
key = _normalise_pair(pair).replace("=X", "")
rates[key] = round(rate, 4)
return rates
# ---------------------------------------------------------------------------
# 1-year history
# ---------------------------------------------------------------------------
def fetch_fx_history(
pair: str,
period: str = "1y",
) -> Tuple[List[str], List[float]]:
"""Fetch historical daily closing rates for a currency pair.
Parameters
----------
pair:
Currency pair string.
period:
yfinance period string (default ``"1y"``).
Returns
-------
tuple of (dates, rates)
dates: list of ISO date strings
rates: list of float closing prices
"""
symbol = _normalise_pair(pair)
cache_key = f"fx_hist:{symbol}:{period}"
cached = _get_cached(cache_key)
if cached is not None:
return cached
try:
import yfinance as yf
ticker = yf.Ticker(symbol)
hist = ticker.history(period=period)
if hist is None or hist.empty:
return ([], [])
dates = [d.strftime("%Y-%m-%d") for d in hist.index]
rates = [round(float(v), 4) for v in hist["Close"]]
result = (dates, rates)
_set_cached(cache_key, result)
return result
except Exception:
return ([], [])
@@ -0,0 +1,204 @@
"""High-level Gemini analysis orchestrators.
Contains the composite analysis functions that combine multiple Gemini
calls (chunked insights, comparative MD&A, industry outlook). These build
on the primitives in :mod:`server.services.gemini_service`.
"""
from typing import Any, Callable, Dict, Optional
from server.services.gemini_service import (
_gemini_forensic_audit,
_gemini_summarize_segment,
_gemini_synthesize_report,
_generate_with_retry,
_is_rate_limit_error,
get_gemini_model,
)
from server.services.text_chunker import clean_text_for_llm, smart_chunk, _split_into_chunks
def get_mda_chunked_insights(
api_key: str,
sections: Dict[str, str],
ticker: str,
sector: str,
industry: str,
progress_callback: Optional[Callable[[str], None]] = None,
) -> str:
"""Full-text analysis: chunk 1A+7, summarise each, synthesise, then append forensic.
Parameters
----------
api_key:
Google Gemini API key.
sections:
Dict with keys ``item1a``, ``item7``, ``item3``, ``item9a``.
ticker:
Stock ticker symbol.
sector / industry:
Used for sector-aware KPI extraction.
progress_callback:
Optional ``fn(msg: str)`` called with status updates.
Returns
-------
str
Markdown-formatted Executive Insight Report.
"""
def _progress(msg: str) -> None:
if progress_callback:
progress_callback(msg)
combined = (sections.get("item1a") or "") + "\n\n---\n\n" + (sections.get("item7") or "")
combined = combined.strip()
if not combined:
return "No 10-K text available to analyse."
chunks = _split_into_chunks(combined, max_chars=22_000)
if not chunks:
return "No content extracted."
summaries = []
n = len(chunks)
for i, ch in enumerate(chunks):
_progress(f"Analyzing Segment {i + 1}/{n}...")
summary = _gemini_summarize_segment(api_key, ch, ticker, f"Segment {i + 1}/{n}")
if summary:
summaries.append(summary)
if not summaries:
return "Segment analysis produced no summaries."
_progress("Synthesizing final report...")
report = _gemini_synthesize_report(api_key, summaries, ticker, sector or "N/A", industry or "N/A")
_progress("Running forensic audit (Item 3 & 9A)...")
forensic = _gemini_forensic_audit(api_key, sections.get("item3") or "", sections.get("item9a") or "", ticker)
return (report or "") + "\n\n---\n\n**Forensic (Item 3 & 9A)**\n\n" + (forensic or "")
def get_mda_insights(
api_key: str,
item1a_text: str,
item7_text: str,
ticker: str,
) -> str:
"""Single-shot analysis of Item 1A + Item 7 (tone, strategy, risks)."""
model = get_gemini_model(api_key)
combined = []
if item1a_text:
combined.append(clean_text_for_llm(item1a_text))
if item7_text:
combined.append(clean_text_for_llm(item7_text))
combined_text = smart_chunk("\n\n---\n\n".join(combined), max_chars=22_000)
prompt = (
f"You are a senior equity analyst. Use British English.\n\n"
f"The text below is from the 10-K for {ticker}: **Item 1A** and **Item 7**.\n\n"
"Provide a concise report:\n"
"1. **Management's Tone (Sentiment)**\n"
"2. **Key Strategic Shifts**\n"
"3. **Major Hidden Risks**\n\n"
"Use clear headings. Under 800 words."
)
full = f"--- 10-K Excerpt ---\n\n{combined_text}\n\n---\n\n{prompt}"
try:
response = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 4096})
except Exception as api_err:
if _is_rate_limit_error(api_err):
raise RuntimeError("Rate limit exceeded. Please try again in a few minutes.") from api_err
raise
if not response or not response.text:
return "No analysis generated."
return response.text.strip()
def get_mda_comparative_insights(
api_key: str,
item1a_text: str,
item7_latest: str,
item7_3y_ago: Optional[str],
ticker: str,
sector: Optional[str] = None,
industry: Optional[str] = None,
) -> str:
"""Comparative or single-year MD&A deep-dive with sector-aware KPIs."""
model = get_gemini_model(api_key)
sector_label = (sector or "N/A").strip()
industry_label = (industry or "N/A").strip()
kpi_instruction = (
f" Given that this company is in the **{sector_label}** sector"
+ (f" (industry: {industry_label})" if industry_label != "N/A" else "")
+ ", extract **industry-specific Non-GAAP KPIs** in a markdown table."
)
if not item7_3y_ago or not item7_3y_ago.strip():
combined = []
if item1a_text:
combined.append(clean_text_for_llm(item1a_text))
if item7_latest:
combined.append(clean_text_for_llm(item7_latest))
combined_text = smart_chunk("\n\n---\n\n".join(combined), max_chars=22_000)
prompt = (
f"You are a senior equity analyst. Use British English.\n"
f"Latest 10-K only for {ticker} (Item 1A + Item 7). Provide:\n"
"1. **Management's Tone**\n2. **Current Strategy & Priorities**\n"
"3. **Major Hidden Risks**\n4. **Forensic / Quality of Earnings**\n"
f"{kpi_instruction}\nUnder 800 words."
)
full = f"--- 10-K Excerpt (Latest Year) ---\n\n{combined_text}\n\n---\n\n{prompt}"
else:
latest_clean = smart_chunk(clean_text_for_llm(item7_latest), max_chars=12_000)
past_clean = smart_chunk(clean_text_for_llm(item7_3y_ago), max_chars=12_000)
prompt = (
f"You are a senior equity analyst. Use British English.\n"
f"Below are Item 7 from the 10-K for {ticker}: LATEST and THREE YEARS AGO.\n"
"1. **Core strategy** changes\n2. **Emerging risks**\n"
"3. **Management's tone** shift\n4. **Industry-specific KPIs**\n"
f"{kpi_instruction}\nUnder 900 words."
)
full = (
f"--- MD&A LATEST YEAR ---\n\n{latest_clean}\n\n"
f"--- MD&A THREE YEARS AGO ---\n\n{past_clean}\n\n---\n\n{prompt}"
)
try:
response = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 4096})
except Exception as api_err:
if _is_rate_limit_error(api_err):
raise RuntimeError("Rate limit exceeded. Please try again in a few minutes.") from api_err
raise
if not response or not response.text:
return "No analysis generated."
return response.text.strip()
def get_industry_outlook(
api_key: str,
industry_name: str,
tickers: list,
) -> str:
"""Generate a Wall Street macro-analyst-style Industry Outlook (12-18 months)."""
model = get_gemini_model(api_key)
ticker_list_str = ", ".join(str(t).upper() for t in tickers if t)
prompt = (
f"Act as an elite Wall Street macro analyst. Provide a concise "
f"**Industry Outlook** for the **{industry_name}** sector, "
f"which includes companies like {ticker_list_str}.\n\n"
"Focus on:\n"
"1. **Macro trends** (next 12-18 months)\n"
"2. **Major growth drivers**\n"
"3. **Key headwinds or regulatory risks**\n\n"
"Use clear headings. Under 600 words."
)
try:
response = _generate_with_retry(model, prompt, {"temperature": 0.4, "max_output_tokens": 2048})
except Exception as api_err:
if _is_rate_limit_error(api_err):
raise RuntimeError("Rate limit exceeded. Please try again in a few minutes.") from api_err
raise
if not response or not response.text:
return "No industry outlook generated."
return response.text.strip()
@@ -0,0 +1,321 @@
"""Gemini LLM integration for qualitative financial analysis.
All functions in this module talk to Google Gemini (via the
``google.generativeai`` SDK) and return plain strings or dicts.
No Streamlit dependencies.
"""
import json
import re
import time
from typing import Any, Dict, Generator, List, Optional
from server.utils.safe_float import _safe_float
from server.services.text_chunker import clean_text_for_llm, smart_chunk, _split_into_chunks
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
GEMINI_MODEL: str = "gemini-2.0-flash"
RATE_LIMIT_WAIT_SEC: int = 60
_REQUIRED_FINANCIAL_KEYS: List[str] = [
"Revenue", "CostOfRevenue", "OperatingExpenses", "NetIncome",
"TotalAssets", "CurrentAssets", "CurrentLiabilities", "LongTermDebt",
"OperatingCashFlow", "SharesOutstanding",
]
# ---------------------------------------------------------------------------
# Model initialisation
# ---------------------------------------------------------------------------
def get_gemini_model(api_key: str) -> Any:
"""Configure and return a ``GenerativeModel`` for :data:`GEMINI_MODEL`."""
import google.generativeai as genai
genai.configure(api_key=api_key)
return genai.GenerativeModel(GEMINI_MODEL)
# ---------------------------------------------------------------------------
# Retry / streaming helpers
# ---------------------------------------------------------------------------
def _is_rate_limit_error(e: Exception) -> bool:
"""Return ``True`` if *e* looks like a 429 / resource-exhausted error."""
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: Any,
content: str,
config: Dict[str, Any],
max_retries: int = 3,
) -> Any:
"""Call ``model.generate_content`` with automatic rate-limit back-off."""
last_err: Optional[Exception] = None
for attempt in range(max_retries + 1):
try:
return model.generate_content(content, generation_config=config)
except Exception as e:
last_err = e
if attempt < max_retries and _is_rate_limit_error(e):
time.sleep(RATE_LIMIT_WAIT_SEC)
continue
raise
raise last_err # type: ignore[misc]
def _generate_stream(
model: Any,
content: str,
config: Dict[str, Any],
) -> Generator[str, None, None]:
"""Yield text chunks from Gemini with ``stream=True``."""
response = model.generate_content(content, generation_config=config, stream=True)
for chunk in response:
if hasattr(chunk, "text") and chunk.text:
yield chunk.text
# ---------------------------------------------------------------------------
# Segment-level helpers (chunked analysis)
# ---------------------------------------------------------------------------
def _gemini_summarize_segment(
api_key: str,
segment_text: str,
ticker: str,
segment_label: str,
) -> str:
"""Summarise one segment of Item 1A / Item 7 text."""
model = get_gemini_model(api_key)
prompt = (
f"You are a senior equity analyst. The following is one segment of "
f"the 10-K for {ticker} (Item 1A Risk Factors and/or Item 7 MD&A).\n"
"Extract and list all significant: (1) strategic shifts or priorities, "
"(2) hidden or material risks, (3) management tone cues. Use concise "
f"bullet points. Do not omit important details. Segment: {segment_label}."
)
full = f"--- 10-K Segment ---\n\n{segment_text[:50000]}\n\n---\n\n{prompt}"
try:
r = _generate_with_retry(model, full, {"temperature": 0.2, "max_output_tokens": 2048})
return (r.text or "").strip()
except Exception:
return ""
def _gemini_synthesize_report(
api_key: str,
segment_summaries: List[str],
ticker: str,
sector: str,
industry: str,
) -> str:
"""Synthesise segment summaries into an Executive Insight Report."""
model = get_gemini_model(api_key)
combined = "\n\n---\n\n".join(segment_summaries)
kpi_note = (
f" Sector: {sector}; Industry: {industry}. Include industry-specific KPIs if mentioned."
if sector and sector != "N/A"
else ""
)
prompt = (
f"You are a senior equity analyst. Use British English. Below are "
f"summarized insights from the full 10-K for {ticker} (Item 1A and "
"Item 7). Create the final **Executive Insight Report** with these sections:\n\n"
"1. **Management's Tone (Sentiment)**: Overall tone and supporting evidence.\n"
"2. **Current Strategy & Priorities**: Key strategic focus, capital allocation, growth drivers.\n"
"3. **Major Hidden Risks**: The 3-4 most material risks investors might overlook.\n"
"4. **Forensic / Quality of Earnings**: Accounting caveats, one-offs, cash flow vs earnings."
f"{kpi_note}\n\n"
"Use clear headings. Do not invent figures. Keep under 900 words."
)
full = f"--- Segment Summaries ---\n\n{combined}\n\n---\n\n{prompt}"
try:
r = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 4096})
return (r.text or "").strip()
except Exception:
return ""
def _gemini_forensic_audit(
api_key: str,
item3: str,
item9a: str,
ticker: str,
) -> str:
"""Check Item 3 & 9A for material weaknesses, lawsuits, red flags."""
model = get_gemini_model(api_key)
combined = (item3 or "") + "\n\n---\n\n" + (item9a or "")
if not combined.strip():
return "No Item 3 / 9A text provided; skip forensic."
prompt = (
f"From the following 10-K excerpts for {ticker} (Item 3 Legal Proceedings "
"and Item 9A Controls/Internal Control), list any:\n"
"- Material weaknesses in internal control\n"
"- Significant legal proceedings or litigation\n"
"- Off-balance-sheet or governance red flags\n"
'If none, output: "No material red flags or special issues detected '
'in Item 3 and 9A."\nBe concise (under 150 words).'
)
full = f"--- Item 3 & 9A ---\n\n{combined[:30000]}\n\n---\n\n{prompt}"
try:
r = _generate_with_retry(model, full, {"temperature": 0.1, "max_output_tokens": 512})
return (r.text or "").strip()
except Exception:
return ""
# ---------------------------------------------------------------------------
# Public analysis functions
# ---------------------------------------------------------------------------
def get_sec_financials_llm(api_key: str, item8_text: str, ticker: str) -> Dict[str, Any]:
"""Extract current/previous year financials from Item 8 via Gemini."""
if not (api_key or "").strip() or not (item8_text or "").strip():
return {}
payload = smart_chunk((item8_text or "").strip(), max_chars=35_000)
model = get_gemini_model(api_key)
prompt = (
f"You are a financial analyst. Below is Item 8 (Financial Statements "
f"and Supplementary Data) from the latest 10-K for {ticker}.\n\n"
"Extract figures for **Current Year** and **Previous Year**. "
"Monetary values in millions. Shares in millions.\n\n"
"Return ONLY valid JSON:\n"
'{"current_yr": {...}, "previous_yr": {...}}\n'
"Keys: Revenue, CostOfRevenue, OperatingExpenses, NetIncome, "
"TotalAssets, CurrentAssets, CurrentLiabilities, LongTermDebt, "
"OperatingCashFlow, SharesOutstanding.\n"
"If not found use 0. Output nothing except JSON."
)
full = f"--- Item 8 ---\n\n{payload}\n\n---\n\n{prompt}"
try:
r = _generate_with_retry(model, full, {"temperature": 0.0, "max_output_tokens": 2048})
raw = (r.text or "").strip()
if not raw:
return {}
raw = re.sub(r"^```\s*json\s*", "", raw)
raw = re.sub(r"^```\s*", "", raw)
raw = re.sub(r"\s*```\s*$", "", raw)
raw = raw.strip()
out = json.loads(raw)
cur = out.get("current_yr") or {}
prev = out.get("previous_yr") or {}
for key in _REQUIRED_FINANCIAL_KEYS:
cur[key] = _safe_float(cur.get(key)) or 0
prev[key] = _safe_float(prev.get(key)) or 0
return {"current_yr": cur, "previous_yr": prev}
except (json.JSONDecodeError, Exception):
return {}
def get_gemini_item7_strategy(
api_key: str,
item7_text: str,
ticker: str,
sector: str,
industry: str,
) -> str:
"""Analyse Item 7 for business performance and strategic shifts."""
if not (item7_text or "").strip():
return "No Item 7 (MD&A) text available."
model = get_gemini_model(api_key)
text = smart_chunk(clean_text_for_llm(item7_text), max_chars=10_000)
sector_note = f" Sector: {sector}; Industry: {industry}." if sector and sector != "N/A" else ""
prompt = (
f"You are a senior equity analyst. Use British English. The text below is "
f"**Item 7 (Management's Discussion and Analysis)** from the latest 10-K for {ticker}.{sector_note}\n\n"
"Provide a concise **Management Strategy** report:\n"
"1. **Business performance**\n2. **Strategic shifts**\n3. **Capital allocation**\n"
"Use clear headings. Under 600 words. Output in British English even if source is another language."
)
full = f"--- Item 7 (MD&A) ---\n\n{text}\n\n---\n\n{prompt}"
try:
r = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 2048})
return (r.text or "").strip()
except Exception:
return ""
def get_gemini_item7_strategy_stream(
api_key: str,
item7_text: str,
ticker: str,
sector: str,
industry: str,
) -> Generator[str, None, None]:
"""Yield MD&A strategy report chunks for real-time streaming."""
if not (item7_text or "").strip():
yield "No Item 7 (MD&A) text available."
return
model = get_gemini_model(api_key)
text = smart_chunk(clean_text_for_llm(item7_text), max_chars=10_000)
sector_note = f" Sector: {sector}; Industry: {industry}." if sector and sector != "N/A" else ""
prompt = (
f"You are a senior equity analyst. Use British English. The text below is "
f"**Item 7 (MD&A)** from the latest 10-K for {ticker}.{sector_note}\n\n"
"Provide a concise **Management Strategy** report:\n"
"1. **Business performance**\n2. **Strategic shifts**\n3. **Capital allocation**\n"
"Under 600 words. British English."
)
full = f"--- Item 7 (MD&A) ---\n\n{text}\n\n---\n\n{prompt}"
yield from _generate_stream(model, full, {"temperature": 0.3, "max_output_tokens": 2048})
def get_gemini_item1a_risks(
api_key: str,
item1a_text: str,
item3: str,
item9a: str,
ticker: str,
) -> str:
"""Analyse Item 1A risks and append forensic audit of Items 3 & 9A."""
if not (item1a_text or "").strip():
return "No Item 1A (Risk Factors) text available."
model = get_gemini_model(api_key)
text = smart_chunk(clean_text_for_llm(item1a_text), max_chars=10_000)
prompt = (
f"You are a senior equity analyst. Use British English. The text below is "
f"**Item 1A (Risk Factors)** from the latest 10-K for {ticker}.\n\n"
"Provide a concise **Risk Factors** report:\n"
"1. **Legal & regulatory risks**\n2. **Operational risks**\n3. **Market & competitive risks**\n"
"Under 500 words. British English."
)
full = f"--- Item 1A ---\n\n{text}\n\n---\n\n{prompt}"
try:
report = _generate_with_retry(model, full, {"temperature": 0.3, "max_output_tokens": 2048})
risks = (report.text or "").strip()
except Exception:
risks = ""
forensic = _gemini_forensic_audit(api_key, item3 or "", item9a or "", ticker)
return (risks or "") + "\n\n---\n\n**Forensic Audit (Item 3 & 9A)**\n\n" + (forensic or "")
def get_gemini_item1a_risks_stream(
api_key: str,
item1a_text: str,
ticker: str,
) -> Generator[str, None, None]:
"""Yield Risk Factors report chunks; caller appends forensic separately."""
if not (item1a_text or "").strip():
yield "No Item 1A (Risk Factors) text available."
return
model = get_gemini_model(api_key)
text = smart_chunk(clean_text_for_llm(item1a_text), max_chars=10_000)
prompt = (
f"You are a senior equity analyst. Use British English. The text below is "
f"**Item 1A (Risk Factors)** from the latest 10-K for {ticker}.\n\n"
"Provide a concise **Risk Factors** report:\n"
"1. **Legal & regulatory risks**\n2. **Operational risks**\n3. **Market & competitive risks**\n"
"Under 500 words. British English."
)
full = f"--- Item 1A ---\n\n{text}\n\n---\n\n{prompt}"
yield from _generate_stream(model, full, {"temperature": 0.3, "max_output_tokens": 2048})
@@ -0,0 +1,218 @@
"""Market data endpoints: DCF inputs, analyst consensus, and comps.
Complements :mod:`server.services.market_fetcher` with higher-level data
retrieval functions that consume the raw financial statements and produce
ready-to-use outputs for the DCF engine and industry comparison panels.
"""
from typing import Dict, Optional
import pandas as pd
from server.utils.safe_float import _safe_float
from server.services.market_fetcher import (
_get_annual_financials_balance_cashflow,
_get_row_series,
)
try:
import yfinance as yf
except ImportError:
yf = None # type: ignore[assignment]
def get_dcf_inputs(ticker: str) -> Dict[str, Optional[float]]:
"""Return FCF, Total Debt, Cash, and Shares Outstanding for DCF.
Tries yahooquery (via ``_get_annual_financials_balance_cashflow``)
first, then falls back to direct yfinance lookups.
Returns
-------
dict
Keys: ``fcf``, ``total_debt``, ``cash``, ``shares`` (any may be ``None``).
"""
out: Dict[str, Optional[float]] = {"fcf": None, "total_debt": 0.0, "cash": 0.0, "shares": None}
if not ticker:
return out
try:
fin, bal, cf = _get_annual_financials_balance_cashflow(ticker)
if bal is not None and not bal.empty and cf is not None and not cf.empty:
sh = _get_row_series(bal, "Share Issued")
out["shares"] = _safe_float(sh.iloc[0]) if sh is not None and len(sh) > 0 else None
td = _get_row_series(bal, "Total Debt")
out["total_debt"] = float(td.iloc[0] or 0) if td is not None and len(td) > 0 else 0.0
cash_s = _get_row_series(bal, "Cash And Cash Equivalents")
out["cash"] = float(cash_s.iloc[0] or 0) if cash_s is not None and len(cash_s) > 0 else 0.0
ocf = _get_row_series(cf, "Operating Cash Flow")
capx = _get_row_series(cf, "Capital Expenditure")
if ocf is not None and len(ocf) > 0:
ocf_val = _safe_float(ocf.iloc[0])
capx_val = _safe_float(capx.iloc[0]) if capx is not None and len(capx) > 0 else 0.0
if ocf_val is not None:
out["fcf"] = ocf_val - (capx_val or 0)
if out.get("fcf") is not None or out.get("shares") is not None:
return out
except Exception:
pass
if not yf:
return out
try:
t = yf.Ticker(ticker.upper())
info = t.info or {}
fast_info = getattr(t, "fast_info", None)
cashflow = getattr(t, "cashflow", None)
if cashflow is None or cashflow.empty:
cashflow = getattr(t, "quarterly_cashflow", None)
balance = getattr(t, "balance_sheet", None)
if balance is None or balance.empty:
balance = getattr(t, "quarterly_balance_sheet", None)
# Shares
shares: Optional[float] = None
if fast_info is not None:
try:
s = getattr(fast_info, "shares", None)
if s is None and hasattr(fast_info, "get"):
s = fast_info.get("shares")
if s is not None and float(s) > 0:
shares = float(s)
except (TypeError, ValueError, AttributeError):
pass
if shares is None:
for key in ("sharesOutstanding", "Shares Outstanding", "impliedSharesOutstanding", "Float Shares"):
s = info.get(key)
if s is not None and float(s) > 0:
shares = float(s)
break
if shares is None and balance is not None and not balance.empty:
try:
if "Share Issued" in balance.index:
shares = _safe_float(balance.loc["Share Issued"].iloc[0])
if (shares is None or shares <= 0) and "Ordinary Shares Number" in balance.index:
shares = _safe_float(balance.loc["Ordinary Shares Number"].iloc[0])
except (KeyError, TypeError, IndexError):
pass
out["shares"] = shares if (shares is not None and shares > 0) else None
# Total Debt
total_debt: Optional[float] = None
if fast_info is not None:
try:
d = getattr(fast_info, "total_debt", None) or (fast_info.get("total_debt") if hasattr(fast_info, "get") else None)
if d is not None and float(d) >= 0:
total_debt = float(d)
except (TypeError, ValueError, AttributeError):
pass
if total_debt is None:
total_debt = info.get("Total Debt")
if total_debt is None and balance is not None and not balance.empty:
try:
if "Total Debt" in balance.index:
total_debt = _safe_float(balance.loc["Total Debt"].iloc[0])
except (KeyError, TypeError, IndexError):
pass
out["total_debt"] = float(total_debt) if total_debt is not None else 0.0
# Cash
cash: Optional[float] = None
if fast_info is not None:
try:
c = getattr(fast_info, "cash", None) or (fast_info.get("cash") if hasattr(fast_info, "get") else None)
if c is not None and float(c) >= 0:
cash = float(c)
except (TypeError, ValueError, AttributeError):
pass
if cash is None:
cash = info.get("Cash And Cash Equivalents") or info.get("Cash")
if cash is None and balance is not None and not balance.empty:
try:
for row_name in ("Cash And Cash Equivalents", "Cash Cash Equivalents And Short Term Investments", "Cash"):
if row_name in balance.index:
cash = _safe_float(balance.loc[row_name].iloc[0])
if cash is not None:
break
except (KeyError, TypeError, IndexError):
pass
out["cash"] = float(cash) if cash is not None else 0.0
# FCF
ocf = _get_row_series(cashflow, "Operating Cash Flow", "Cash From Operating Activities", "Cash From Operations") if cashflow is not None else None
capx = _get_row_series(cashflow, "Capital Expenditure", "Capital Expenditures", "Purchase Of Property Plant And Equipment") if cashflow is not None else None
if ocf is not None and len(ocf) > 0:
ocf_val = _safe_float(ocf.iloc[0])
capx_val = _safe_float(capx.iloc[0]) if capx is not None and len(capx) > 0 else 0.0
if capx_val is None:
capx_val = 0.0
if ocf_val is not None:
latest_fcf = ocf_val - capx_val
if latest_fcf == latest_fcf and not (isinstance(latest_fcf, float) and pd.isna(latest_fcf)):
out["fcf"] = latest_fcf
return out
except Exception:
return out
def get_analyst_consensus(ticker: str) -> Dict[str, str]:
"""Fetch analyst consensus from yfinance: target price, recommendation, growth."""
out = {"targetMeanPrice": "N/A", "recommendationKey": "N/A", "revenueGrowth": "N/A", "earningsGrowth": "N/A"}
if not yf or not ticker:
return out
try:
t = yf.Ticker(ticker.upper())
info = t.info or {}
tp = info.get("targetMeanPrice")
if tp is not None:
try:
out["targetMeanPrice"] = f"${float(tp):.2f}"
except (TypeError, ValueError):
out["targetMeanPrice"] = str(tp)
rec = info.get("recommendationKey") or info.get("recommendation")
if rec is not None:
out["recommendationKey"] = str(rec)
rg = info.get("revenueGrowth")
if rg is not None:
try:
out["revenueGrowth"] = f"{float(rg) * 100:.1f}%"
except (TypeError, ValueError):
out["revenueGrowth"] = str(rg)
eg = info.get("earningsGrowth")
if eg is not None:
try:
out["earningsGrowth"] = f"{float(eg) * 100:.1f}%"
except (TypeError, ValueError):
out["earningsGrowth"] = str(eg)
return out
except Exception:
return out
def get_comps_data(tickers: tuple) -> pd.DataFrame:
"""Fetch Forward P/E, EV/EBITDA, P/B for a set of tickers."""
if not yf:
return pd.DataFrame()
rows = []
for sym in tickers:
sym = str(sym).strip().upper()
if not sym:
continue
try:
t = yf.Ticker(sym)
info = t.info or {}
forward_pe = info.get("forwardPE") or info.get("Forward PE") or info.get("trailingPE") or info.get("Trailing PE")
ev_ebitda = info.get("enterpriseToEbitda")
if ev_ebitda is None:
ev, ebitda = info.get("enterpriseValue"), info.get("ebitda")
if ev is not None and ebitda is not None and ebitda != 0:
ev_ebitda = ev / ebitda
pb = info.get("priceToBook") or info.get("Price To Book")
rows.append({
"Ticker": sym,
"Forward P/E": round(float(forward_pe), 2) if forward_pe is not None and _safe_float(forward_pe) is not None else None,
"EV/EBITDA": round(float(ev_ebitda), 2) if ev_ebitda is not None and _safe_float(ev_ebitda) is not None else None,
"P/B": round(float(pb), 2) if pb is not None and _safe_float(pb) is not None else None,
})
except Exception:
rows.append({"Ticker": sym, "Forward P/E": None, "EV/EBITDA": None, "P/B": None})
return pd.DataFrame(rows) if rows else pd.DataFrame()
@@ -0,0 +1,277 @@
"""Yahoo Finance / yahooquery data fetching for financial statements.
Provides functions to retrieve annual income statements, balance sheets,
cash-flow statements, sector/industry metadata, DCF inputs, analyst
consensus, and peer-comparable multiples. Uses yahooquery as the primary
source with yfinance as fallback; builds TTM aggregates from quarterly
data when annual data is unavailable.
"""
from typing import Dict, List, Optional, Tuple
import pandas as pd
from server.utils.safe_float import _safe_float
# ---------------------------------------------------------------------------
# Optional imports
# ---------------------------------------------------------------------------
try:
import yfinance as yf
except ImportError:
yf = None # type: ignore[assignment]
try:
from yahooquery import Ticker as YQTicker
except ImportError:
YQTicker = None # type: ignore[assignment]
# ---------------------------------------------------------------------------
# Row-mapping tables (yahooquery column names -> our canonical names)
# ---------------------------------------------------------------------------
_INCOME_ROW_MAP: List[Tuple[str, Tuple[str, ...]]] = [
("Total Revenue", ("TotalRevenue", "OperatingRevenue", "TotalRevenue")),
("Cost Of Revenue", ("CostOfRevenue", "ReconciledCostOfRevenue")),
("Gross Profit", ("GrossProfit",)),
("Operating Income", ("OperatingIncome", "EBIT", "TotalOperatingIncomeAsReported")),
("Net Income", ("NetIncome", "NetIncomeCommonStockholders", "NetIncomeContinuousOperations", "DilutedNIAvailtoComStockholders")),
("Operating Expense", ("OperatingExpense", "OperatingExpenses", "TotalExpenses")),
("Interest Expense", ("InterestExpense", "InterestExpenseNonOperating")),
("Research And Development Expenses", ("ResearchAndDevelopment", "ResearchAndDevelopmentExpenses")),
]
_BALANCE_ROW_MAP: List[Tuple[str, Tuple[str, ...]]] = [
("Total Assets", ("TotalAssets",)),
("Total Stockholder Equity", ("StockholdersEquity", "CommonStockEquity", "TotalEquityGrossMinorityInterest")),
("Total Liabilities", ("TotalLiabilitiesNetMinorityInterest", "TotalLiabilities")),
("Current Assets", ("CurrentAssets",)),
("Current Liabilities", ("CurrentLiabilities",)),
("Long Term Debt", ("LongTermDebt", "LongTermDebtAndCapitalLeaseObligation")),
("Total Debt", ("TotalDebt",)),
("Share Issued", ("OrdinarySharesNumber", "ShareIssued", "BasicAverageShares", "DilutedAverageShares")),
("Cash And Cash Equivalents", ("CashAndCashEquivalents", "CashCashEquivalentsAndShortTermInvestments", "EndCashPosition")),
("Retained Earnings", ("RetainedEarnings",)),
]
_CASHFLOW_ROW_MAP: List[Tuple[str, Tuple[str, ...]]] = [
("Operating Cash Flow", ("OperatingCashFlow", "CashFromOperatingActivities")),
("Capital Expenditure", ("CapitalExpenditure", "CapitalExpenditures")),
]
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _yq_df_to_our_shape(
df: pd.DataFrame,
row_map: List[Tuple[str, Tuple[str, ...]]],
date_col: str = "asOfDate",
) -> Optional[pd.DataFrame]:
"""Pivot a yahooquery DataFrame to index=line-items, columns=dates."""
if df is None or df.empty or date_col not in df.columns:
return None
df = df.dropna(subset=[date_col]).sort_values(date_col, ascending=False).head(5)
if df.empty:
return None
dates = df[date_col].astype(str).str[:10].tolist()
data: Dict[str, list] = {}
for our_name, yq_cols in row_map:
cols = yq_cols if isinstance(yq_cols, tuple) else (yq_cols,)
val_col = next((c for c in cols if c in df.columns), None)
if val_col is None:
data[our_name] = [None] * len(dates)
else:
data[our_name] = [_safe_float(v) for v in df[val_col].tolist()]
out = pd.DataFrame(data, index=dates).T
out.columns = dates
return out
def _share_issued_from_yq_balance(df_bal: pd.DataFrame) -> Optional[pd.Series]:
"""Extract shares outstanding series from yahooquery balance sheet."""
if df_bal is None or df_bal.empty:
return None
for col in ("OrdinarySharesNumber", "ShareIssued"):
if col in df_bal.columns and "asOfDate" in df_bal.columns:
s = df_bal.set_index("asOfDate")[col].sort_index(ascending=False)
s.index = s.index.astype(str).str[:10]
return s
return None
def _get_row_series(df: Optional[pd.DataFrame], *names: str) -> Optional[pd.Series]:
"""Return the first matching row from *df* as a Series, or ``None``."""
if df is None or df.empty:
return None
for name in names:
try:
if name in df.index:
return df.loc[name].copy()
except (KeyError, TypeError):
continue
return None
def _fin_or_bal_empty(df: object) -> bool:
"""True if *df* is missing, empty, or has no columns."""
return df is None or (hasattr(df, "empty") and df.empty) or (hasattr(df, "columns") and len(df.columns) == 0)
# ---------------------------------------------------------------------------
# Core fetchers
# ---------------------------------------------------------------------------
def _get_annual_financials_balance_cashflow_yahooquery(
ticker: str,
) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame], Optional[pd.DataFrame]]:
"""Fetch annual financials from yahooquery with TTM fallback."""
if not YQTicker or not ticker:
return (None, None, None)
try:
yq = YQTicker(ticker.upper())
inc_a = yq.income_statement(frequency="a", trailing=False)
bal_a = yq.balance_sheet(frequency="a", trailing=False)
cf_a = yq.cash_flow(frequency="a", trailing=False)
if inc_a is None or inc_a.empty or bal_a is None or bal_a.empty:
inc_q = yq.income_statement(frequency="q", trailing=False)
bal_q = yq.balance_sheet(frequency="q", trailing=False)
cf_q = yq.cash_flow(frequency="q", trailing=False)
if inc_q is not None and not inc_q.empty and len(inc_q) >= 4:
ttm0 = inc_q.head(4).sum(numeric_only=True)
row0 = ttm0.to_dict()
row0["asOfDate"] = inc_q["asOfDate"].iloc[0] if "asOfDate" in inc_q.columns else "TTM0"
rows_inc = [row0]
if len(inc_q) >= 8:
ttm1 = inc_q.iloc[4:8].sum(numeric_only=True)
row1 = ttm1.to_dict()
row1["asOfDate"] = inc_q["asOfDate"].iloc[4] if "asOfDate" in inc_q.columns else "TTM1"
rows_inc.append(row1)
inc_a = pd.DataFrame(rows_inc)
if bal_q is not None and not bal_q.empty:
bal_a = bal_q.head(2) if (bal_a is None or bal_a.empty) else bal_a
if cf_q is not None and not cf_q.empty and len(cf_q) >= 4 and (cf_a is None or cf_a.empty):
ttm0_cf = cf_q.head(4).sum(numeric_only=True)
row0_cf = ttm0_cf.to_dict()
row0_cf["asOfDate"] = cf_q["asOfDate"].iloc[0] if "asOfDate" in cf_q.columns else "TTM0"
rows_cf = [row0_cf]
if len(cf_q) >= 8:
ttm1_cf = cf_q.iloc[4:8].sum(numeric_only=True)
row1_cf = ttm1_cf.to_dict()
row1_cf["asOfDate"] = cf_q["asOfDate"].iloc[4] if "asOfDate" in cf_q.columns else "TTM1"
rows_cf.append(row1_cf)
cf_a = pd.DataFrame(rows_cf)
fin_df = _yq_df_to_our_shape(inc_a, _INCOME_ROW_MAP)
bal_df = _yq_df_to_our_shape(bal_a, _BALANCE_ROW_MAP)
if bal_df is not None and "Share Issued" not in bal_df.index and bal_a is not None and not bal_a.empty:
for sh_col in ("OrdinarySharesNumber", "ShareIssued"):
if sh_col in bal_a.columns:
row = {"Share Issued": [_safe_float(bal_a[sh_col].iloc[0])]}
if bal_df is not None and not bal_df.empty:
d = str(bal_a["asOfDate"].iloc[0])[:10] if "asOfDate" in bal_a.columns else bal_df.columns[0]
extra = pd.DataFrame(row, index=[d]).T
extra.columns = [d]
bal_df = pd.concat([bal_df, extra], axis=0)
break
cf_df = _yq_df_to_our_shape(cf_a, _CASHFLOW_ROW_MAP)
return (fin_df, bal_df, cf_df)
except Exception:
return (None, None, None)
def _get_annual_financials_balance_cashflow(
ticker: str,
) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame], Optional[pd.DataFrame]]:
"""Return ``(fin_df, bal_df, cf_df)`` using yahooquery then yfinance fallback."""
if not ticker:
return (None, None, None)
fin_df, bal_df, cf_df = _get_annual_financials_balance_cashflow_yahooquery(ticker)
if fin_df is not None and not fin_df.empty and bal_df is not None and not bal_df.empty:
return (fin_df, bal_df, cf_df)
if not yf:
return (None, None, None)
try:
t = yf.Ticker(ticker.upper())
fin = getattr(t, "financials", None)
bal = getattr(t, "balance_sheet", None)
cf = getattr(t, "cashflow", None)
if _fin_or_bal_empty(fin):
qf = getattr(t, "quarterly_financials", None)
if qf is not None and not qf.empty:
n = len(qf.columns)
if n >= 8:
fin = pd.concat([qf.iloc[:, :4].sum(axis=1), qf.iloc[:, 4:8].sum(axis=1)], axis=1)
fin.columns = ["TTM0", "TTM1"]
elif n >= 5:
fin = pd.concat([qf.iloc[:, :4].sum(axis=1), qf.iloc[:, 4:n].sum(axis=1)], axis=1)
fin.columns = ["TTM0", "TTM1"]
else:
fin = qf.iloc[:, :min(4, n)].sum(axis=1).to_frame("TTM0")
if _fin_or_bal_empty(bal):
qb = getattr(t, "quarterly_balance_sheet", None)
if qb is not None and not qb.empty:
n = len(qb.columns)
bal = qb.iloc[:, :min(2, n)].copy()
bal.columns = ["B0", "B1"] if bal.shape[1] >= 2 else ["B0"]
if _fin_or_bal_empty(cf):
qc = getattr(t, "quarterly_cashflow", None)
if qc is not None and not qc.empty:
cf = qc.iloc[:, :min(4, len(qc.columns))].sum(axis=1).to_frame("TTM0")
return (fin, bal, cf)
except Exception:
return (None, None, None)
def get_sector_industry(ticker: str) -> Dict[str, str]:
"""Return ``{'sector': ..., 'industry': ...}`` from yfinance."""
if not yf:
return {"sector": "N/A", "industry": "N/A"}
try:
t = yf.Ticker(ticker.upper())
info = t.info or {}
sector = (info.get("sector") or info.get("sectorDisp") or "N/A").strip() or "N/A"
industry = (info.get("industry") or info.get("industryDisp") or "N/A").strip() or "N/A"
return {"sector": sector, "industry": industry}
except Exception:
return {"sector": "N/A", "industry": "N/A"}
def get_5yr_financial_trend(ticker: str) -> pd.DataFrame:
"""Up to 5 years of Revenue, Net Income, Operating Margin, FCF."""
if not yf:
return pd.DataFrame()
try:
t = yf.Ticker(ticker.upper())
financials = t.financials
cashflow = t.cashflow
if financials is None or financials.empty or cashflow is None or cashflow.empty:
return pd.DataFrame()
dates = sorted(financials.columns.tolist(), reverse=True)[:5]
ocf = _get_row_series(cashflow, "Operating Cash Flow", "Cash From Operating Activities", "Cash From Operations")
capx = _get_row_series(cashflow, "Capital Expenditure", "Capital Expenditures", "Purchase Of Property Plant And Equipment")
revenue = _get_row_series(financials, "Total Revenue", "Revenue", "Net Revenue")
ni = _get_row_series(financials, "Net Income", "Net Income Common Stockholders")
op_income = _get_row_series(financials, "Operating Income", "EBIT")
rows = []
for d in dates:
yr = d.year if hasattr(d, "year") else int(str(d)[:4])
rev = _safe_float(revenue.get(d)) if revenue is not None and d in revenue.index else None
net_i = _safe_float(ni.get(d)) if ni is not None and d in ni.index else None
op_i = _safe_float(op_income.get(d)) if op_income is not None and d in op_income.index else None
oper_margin = (op_i / rev * 100) if (op_i is not None and rev and rev != 0) else ((net_i / rev * 100) if (net_i is not None and rev and rev != 0) else None)
ocf_val = _safe_float(ocf.get(d)) if ocf is not None and d in ocf.index else None
capx_val = _safe_float(capx.get(d)) if capx is not None and d in capx.index else None
fcf = (ocf_val - capx_val) if (ocf_val is not None and capx_val is not None) else (ocf_val if ocf_val is not None else None)
rows.append({
"Year": yr,
"Revenue": rev,
"Net Income": net_i,
"Operating Margin %": round(oper_margin, 2) if oper_margin is not None else None,
"FCF": fcf,
})
return pd.DataFrame(rows)
except Exception:
return pd.DataFrame()
@@ -0,0 +1,124 @@
"""Monte Carlo simulation for DCF valuation.
Runs N random DCF scenarios by sampling WACC and FCF growth from
normal distributions, then reports distributional statistics.
"""
from typing import Dict, Any, List
import numpy as np
def run_monte_carlo_dcf(
fcf: float,
wacc_mean: float,
wacc_std: float,
growth_mean: float,
growth_std: float,
term_growth: float,
total_debt: float,
cash: float,
shares: float,
n_simulations: int = 5000,
current_price: float | None = None,
) -> Dict[str, Any]:
"""Run a Monte Carlo DCF simulation.
Parameters
----------
fcf : float
Base free cash flow.
wacc_mean / wacc_std : float
Mean and standard deviation for WACC sampling (decimal, e.g. 0.09).
growth_mean / growth_std : float
Mean and standard deviation for FCF growth sampling (decimal).
term_growth : float
Terminal growth rate (constant across simulations).
total_debt, cash, shares : float
Balance-sheet items for equity bridge.
n_simulations : int
Number of Monte Carlo iterations (default 5 000).
current_price : float | None
Current market price; used to compute prob_above_current.
Returns
-------
dict
values list of per-share intrinsic values (sorted)
percentile_10 10th percentile
median 50th percentile
percentile_90 90th percentile
mean arithmetic mean
prob_above_current probability the simulated value exceeds current_price
current_price echo back
n_simulations echo back
"""
if shares <= 0 or fcf <= 0:
return {
"values": [],
"percentile_10": None,
"median": None,
"percentile_90": None,
"mean": None,
"prob_above_current": None,
"current_price": current_price,
"n_simulations": n_simulations,
}
rng = np.random.default_rng()
# Sample WACC and growth; clip to sensible bounds
waccs = rng.normal(wacc_mean, max(wacc_std, 1e-6), n_simulations)
waccs = np.clip(waccs, 0.01, 0.40)
growths = rng.normal(growth_mean, max(growth_std, 1e-6), n_simulations)
growths = np.clip(growths, -0.30, 0.60)
projection_years = 10
values: List[float] = []
for w, g in zip(waccs, growths):
if w <= term_growth:
continue
# 10-year two-stage DCF (simplified: constant growth then terminal)
pv = 0.0
fcft = float(fcf)
for t in range(1, projection_years + 1):
fcft *= (1 + g)
pv += fcft / ((1 + w) ** t)
tv = fcft * (1 + term_growth) / (w - term_growth)
pv += tv / ((1 + w) ** projection_years)
equity = pv - total_debt + cash
per_share = equity / shares
if per_share > 0:
values.append(round(per_share, 2))
if not values:
return {
"values": [],
"percentile_10": None,
"median": None,
"percentile_90": None,
"mean": None,
"prob_above_current": None,
"current_price": current_price,
"n_simulations": n_simulations,
}
arr = np.array(values)
arr.sort()
prob_above = None
if current_price is not None and current_price > 0:
prob_above = round(float(np.mean(arr > current_price) * 100), 1)
return {
"values": arr.tolist(),
"percentile_10": round(float(np.percentile(arr, 10)), 2),
"median": round(float(np.median(arr)), 2),
"percentile_90": round(float(np.percentile(arr, 90)), 2),
"mean": round(float(np.mean(arr)), 2),
"prob_above_current": prob_above,
"current_price": current_price,
"n_simulations": n_simulations,
}
@@ -0,0 +1,186 @@
"""News aggregation from Finviz RSS and Google News RSS.
Fetches, deduplicates, and sorts financial news articles for a given
ticker/company combination. No API keys required -- uses public RSS feeds.
"""
import re
import time
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from urllib.request import Request, urlopen
from urllib.error import URLError
from email.utils import parsedate_to_datetime
_USER_AGENT = "ATLAS-Terminal/1.0 (news aggregator)"
_TIMEOUT_SEC = 10
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _fetch_xml(url: str) -> Optional[str]:
"""Fetch a URL and return its body as a string, or ``None`` on error."""
try:
req = Request(url, headers={"User-Agent": _USER_AGENT})
with urlopen(req, timeout=_TIMEOUT_SEC) as resp:
return resp.read().decode("utf-8", errors="replace")
except (URLError, OSError, Exception):
return None
def _parse_rss_items(xml_text: str) -> List[Dict[str, Any]]:
"""Parse standard RSS 2.0 ``<item>`` elements into dicts."""
items: List[Dict[str, Any]] = []
if not xml_text:
return items
try:
root = ET.fromstring(xml_text)
except ET.ParseError:
return items
for item in root.iter("item"):
title = (item.findtext("title") or "").strip()
link = (item.findtext("link") or "").strip()
pub_date_str = (item.findtext("pubDate") or "").strip()
description = (item.findtext("description") or "").strip()
source = (item.findtext("source") or "").strip()
pub_dt: Optional[datetime] = None
if pub_date_str:
try:
pub_dt = parsedate_to_datetime(pub_date_str)
except (ValueError, TypeError):
pass
if title and link:
items.append({
"title": title,
"link": link,
"published": pub_dt.isoformat() if pub_dt else pub_date_str,
"published_dt": pub_dt,
"description": description[:500] if description else "",
"source": source,
})
return items
def _dedup_by_title(articles: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Remove duplicate articles based on normalised title."""
seen: set = set()
unique: List[Dict[str, Any]] = []
for art in articles:
key = re.sub(r"\s+", " ", art["title"].lower().strip())
if key not in seen:
seen.add(key)
unique.append(art)
return unique
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def fetch_finviz_news(ticker: str) -> List[Dict[str, Any]]:
"""Fetch recent news for *ticker* from the Finviz RSS feed.
Parameters
----------
ticker:
Stock ticker symbol (e.g. ``'AAPL'``).
Returns
-------
list[dict]
Each dict has keys: ``title``, ``link``, ``published``,
``description``, ``source``.
"""
if not ticker or not ticker.strip():
return []
url = f"https://finviz.com/quote.ashx?t={ticker.strip().upper()}&ty=c&p=d&b=1"
# Finviz RSS endpoint
rss_url = f"https://finviz.com/news_export.ashx?t={ticker.strip().upper()}"
xml = _fetch_xml(rss_url)
if not xml:
return []
items = _parse_rss_items(xml)
for item in items:
if not item.get("source"):
item["source"] = "Finviz"
return items
def fetch_google_news(company_name: str) -> List[Dict[str, Any]]:
"""Fetch recent news for *company_name* from Google News RSS.
Parameters
----------
company_name:
Full company name (e.g. ``'Apple Inc.'``).
Returns
-------
list[dict]
Same structure as :func:`fetch_finviz_news`.
"""
if not company_name or not company_name.strip():
return []
# URL-encode the query
query = company_name.strip().replace(" ", "+")
rss_url = f"https://news.google.com/rss/search?q={query}+stock&hl=en-US&gl=US&ceid=US:en"
xml = _fetch_xml(rss_url)
if not xml:
return []
items = _parse_rss_items(xml)
for item in items:
if not item.get("source"):
item["source"] = "Google News"
return items
def aggregate_news(
ticker: str,
company_name: str,
max_articles: int = 30,
) -> List[Dict[str, Any]]:
"""Aggregate news from Finviz and Google News, deduplicated and sorted.
Parameters
----------
ticker:
Stock ticker symbol.
company_name:
Full company name for broader search coverage.
max_articles:
Maximum number of articles to return (default 30).
Returns
-------
list[dict]
Deduplicated articles sorted by publication time (newest first).
Each dict has: ``title``, ``link``, ``published``, ``description``,
``source``.
"""
finviz_articles = fetch_finviz_news(ticker)
google_articles = fetch_google_news(company_name)
all_articles = finviz_articles + google_articles
unique = _dedup_by_title(all_articles)
# Sort by datetime (newest first); articles without a parseable date go last
def sort_key(art: Dict[str, Any]) -> float:
dt = art.get("published_dt")
if dt is not None:
return -dt.timestamp()
return float("inf")
unique.sort(key=sort_key)
# Strip internal datetime field before returning
for art in unique:
art.pop("published_dt", None)
return unique[:max_articles]
@@ -0,0 +1,98 @@
"""Portfolio risk metrics -- VaR, Sharpe, Sortino, MDD, Beta, Correlation."""
import numpy as np
def compute_portfolio_risk(positions: list, benchmark: str = "SPY") -> dict:
"""Compute VaR, Sharpe, Sortino, MDD, Beta, Correlation for portfolio."""
import yfinance as yf
tickers = [p["ticker"] for p in positions]
if not tickers:
return {}
values = [
p.get("value", p.get("quantity", 0) * p.get("avg_price", 0))
for p in positions
]
total = sum(values) or 1
weights = np.array([v / total for v in values])
data = yf.download(tickers + [benchmark], period="1y", progress=False)["Close"]
if data.empty:
return {}
returns = data.pct_change().dropna()
if len(tickers) == 1:
port_returns = (
returns[tickers[0]]
if tickers[0] in returns.columns
else returns.iloc[:, 0]
)
else:
ticker_returns = (
returns[tickers]
if all(t in returns.columns for t in tickers)
else returns.iloc[:, : len(tickers)]
)
port_returns = (ticker_returns * weights).sum(axis=1)
bench_returns = (
returns[benchmark] if benchmark in returns.columns else returns.iloc[:, -1]
)
# VaR
var_95 = float(np.percentile(port_returns, 5))
var_99 = float(np.percentile(port_returns, 1))
# Sharpe (annualized, rf=0.04)
rf_daily = 0.04 / 252
excess = port_returns - rf_daily
sharpe = (
float(np.sqrt(252) * excess.mean() / excess.std())
if excess.std() > 0
else 0
)
# Sortino
downside = excess[excess < 0]
sortino = (
float(np.sqrt(252) * excess.mean() / downside.std())
if len(downside) > 0 and downside.std() > 0
else 0
)
# Max Drawdown
cumulative = (1 + port_returns).cumprod()
peak = cumulative.expanding().max()
drawdown = (cumulative - peak) / peak
max_dd = float(drawdown.min())
# Beta
cov = np.cov(port_returns, bench_returns)
beta = float(cov[0, 1] / cov[1, 1]) if cov[1, 1] > 0 else 1.0
# Correlation matrix
corr = {}
if len(tickers) > 1:
corr_df = (
returns[tickers].corr()
if all(t in returns.columns for t in tickers)
else {}
)
if hasattr(corr_df, "to_dict"):
corr = {
str(k): {str(k2): round(v2, 3) for k2, v2 in v.items()}
for k, v in corr_df.to_dict().items()
}
return {
"var_95": round(var_95 * 100, 2),
"var_99": round(var_99 * 100, 2),
"sharpe": round(sharpe, 2),
"sortino": round(sortino, 2),
"max_drawdown": round(max_dd * 100, 2),
"beta": round(beta, 2),
"correlation_matrix": corr,
}
@@ -0,0 +1,145 @@
"""Portfolio screenshot OCR using Gemini Vision.
Analyses screenshots from Trading 212 or Interactive Brokers (IBKR) portfolio
views and extracts structured position data (ticker, quantity, market value,
gain/loss) via the Gemini multimodal API.
"""
import json
import re
from typing import Any, Dict, List, Optional
def _get_vision_model(api_key: str) -> Any:
"""Configure Gemini and return a multimodal model."""
import google.generativeai as genai
genai.configure(api_key=api_key)
return genai.GenerativeModel("gemini-2.0-flash")
def _build_prompt() -> str:
"""Return the extraction prompt for portfolio screenshots."""
return """You are a financial data extraction assistant.
Analyse this portfolio screenshot from a brokerage app (Trading 212,
Interactive Brokers, or similar).
Extract every visible position and return ONLY a valid JSON object with
this structure:
{
"broker": "Trading 212" | "IBKR" | "Unknown",
"currency": "USD" | "GBP" | "EUR" | ...,
"positions": [
{
"ticker": "AAPL",
"name": "Apple Inc.",
"quantity": 10.5,
"avg_price": 150.00,
"current_price": 175.00,
"market_value": 1837.50,
"gain_loss": 262.50,
"gain_loss_pct": 16.67
}
],
"total_value": 50000.00,
"total_gain_loss": 5000.00
}
Rules:
- Use null for any field you cannot read.
- quantity may be fractional (e.g. 0.125 shares).
- Monetary values should be plain numbers, no currency symbols.
- If the screenshot is not a portfolio view, return {"error": "Not a portfolio screenshot"}.
- Output ONLY the JSON object, nothing else.
"""
def analyze_portfolio_screenshot(
api_key: str,
image_bytes: bytes,
) -> Dict[str, Any]:
"""Extract portfolio positions from a brokerage screenshot.
Uses Gemini Vision (multimodal) to read the image and return
structured position data.
Parameters
----------
api_key:
Google Gemini API key.
image_bytes:
Raw bytes of the screenshot image (PNG, JPEG, etc.).
Returns
-------
dict
Parsed portfolio data with ``broker``, ``currency``,
``positions`` (list), ``total_value``, and ``total_gain_loss``.
On error, returns ``{"error": "<description>"}``.
"""
if not api_key or not api_key.strip():
return {"error": "API key is required."}
if not image_bytes:
return {"error": "No image data provided."}
try:
model = _get_vision_model(api_key)
except Exception as e:
return {"error": f"Failed to initialise Gemini Vision: {e}"}
prompt = _build_prompt()
# Build multimodal content: image + text prompt
try:
import google.generativeai as genai
# Detect MIME type from magic bytes
mime_type = "image/png"
if image_bytes[:3] == b"\xff\xd8\xff":
mime_type = "image/jpeg"
elif image_bytes[:4] == b"\x89PNG":
mime_type = "image/png"
elif image_bytes[:4] == b"RIFF":
mime_type = "image/webp"
image_part = {"mime_type": mime_type, "data": image_bytes}
response = model.generate_content(
[image_part, prompt],
generation_config={"temperature": 0.0, "max_output_tokens": 4096},
)
raw = (response.text or "").strip()
if not raw:
return {"error": "Gemini returned an empty response."}
# Strip markdown code fences if present
raw = re.sub(r"^```\s*json\s*", "", raw)
raw = re.sub(r"^```\s*", "", raw)
raw = re.sub(r"\s*```\s*$", "", raw)
raw = raw.strip()
result: Dict[str, Any] = json.loads(raw)
# Validate structure
if "error" in result:
return result
if "positions" not in result:
return {"error": "Response missing 'positions' key.", "raw": raw}
# Coerce numeric fields
for pos in result.get("positions", []):
for key in ("quantity", "avg_price", "current_price", "market_value", "gain_loss", "gain_loss_pct"):
val = pos.get(key)
if val is not None:
try:
pos[key] = float(val)
except (TypeError, ValueError):
pos[key] = None
return result
except json.JSONDecodeError:
return {"error": "Failed to parse JSON from Gemini response.", "raw": raw}
except Exception as e:
return {"error": f"Screenshot analysis failed: {e}"}
@@ -0,0 +1,382 @@
"""SEC EDGAR 10-K download, parsing, section extraction, and caching.
Handles the full pipeline from downloading a 10-K filing via
``sec_edgar_downloader`` through HTML stripping to isolating individual
Item sections (1A, 3, 7, 8, 9A) and persisting the cleaned text to a
local JSON cache under ``data/``.
"""
import json
import re
import tempfile
from pathlib import Path
from typing import Dict, List, Optional
from bs4 import BeautifulSoup
from server.services.text_chunker import clean_text_for_llm, smart_chunk
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
_DATA_DIR: Path = Path(__file__).resolve().parents[3] / "data"
# ---------------------------------------------------------------------------
# Section-header regex patterns
# ---------------------------------------------------------------------------
ITEM1A_PATTERNS: List[str] = [
r"Item\s+1A\s*[.:]\s*Risk\s+Factors",
r"ITEM\s+1A\s*[.:]\s*Risk\s+Factors",
]
ITEM7_PATTERNS: List[str] = [
r"Item\s+7\s*[.:]\s*Management['\u2019]s\s+Discussion\s+and\s+Analysis",
r"ITEM\s+7\s*[.:]\s*Management['\u2019]s\s+Discussion",
r"Item\s+7\s*[.:]\s*[\w\s]+MD&A",
]
ITEM8_PATTERNS: List[str] = [
r"Item\s+8\s*[.:]\s*Financial\s+Statements",
r"ITEM\s+8\s*[.:]\s*Financial\s+Statements",
]
ITEM3_PATTERNS: List[str] = [
r"Item\s+3\s*[.:]\s*Legal\s+Proceedings",
r"ITEM\s+3\s*[.:]\s*Legal\s+Proceedings",
]
ITEM9A_PATTERNS: List[str] = [
r"Item\s+9A\s*[.:]\s*Controls\s+and\s+Procedures",
r"Item\s+9A\s*[.:]\s*Internal\s+Control",
r"ITEM\s+9A\s*[.:]\s*Controls",
]
# ---------------------------------------------------------------------------
# HTML helpers
# ---------------------------------------------------------------------------
def _slice_html_items_1a_to_9a(raw_html: str) -> str:
"""Fast string-level slice: keep only Item 1A through end of Item 9A."""
if not raw_html or len(raw_html) < 5000:
return raw_html
start = -1
for needle in ("Item 1A", "ITEM 1A", "Item 1a"):
i = raw_html.find(needle)
if i != -1 and (start == -1 or i < start):
start = i
if start == -1:
m = re.search(r"Item\s+1A\s", raw_html, re.IGNORECASE)
start = m.start() if m else 0
else:
start = max(0, start - 200)
search_region = raw_html[start:]
end_match = re.search(
r"Item\s+10\s|Item\s+12\s|Part\s+III\b|PART\s+III\b",
search_region,
re.IGNORECASE,
)
end = start + end_match.start() if end_match else len(raw_html)
end = min(end, start + 8_000_000)
return raw_html[start:end]
def _extract_text_from_html_string(html_str: str) -> str:
"""Parse an HTML string and return plain text (tables/scripts removed)."""
if not html_str or not html_str.strip():
return ""
try:
soup = BeautifulSoup(html_str, "lxml")
except Exception:
soup = BeautifulSoup(html_str, "html.parser")
for tag in soup.find_all(["table", "img", "svg", "style", "script"]):
tag.decompose()
return soup.get_text(separator="\n", strip=True)
def extract_text_from_html(html_path: Path) -> str:
"""Read an HTML file, slice to Items 1A-9A, and return plain text."""
try:
with open(html_path, "r", encoding="utf-8", errors="replace") as f:
raw = f.read()
except Exception:
with open(html_path, "r", encoding="latin-1", errors="replace") as f:
raw = f.read()
chunk = _slice_html_items_1a_to_9a(raw)
return _extract_text_from_html_string(chunk)
def extract_text_from_file(file_path: Path) -> str:
"""Extract plain text from an HTML or TXT file."""
suf = file_path.suffix.lower()
if suf in (".htm", ".html"):
return extract_text_from_html(file_path)
if suf == ".txt":
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
text = f.read()
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text)
return text
return ""
# ---------------------------------------------------------------------------
# Section finders
# ---------------------------------------------------------------------------
def _find_section_start(text: str, patterns: List[str], item_num: int) -> int:
"""Return character offset where *item_num* section begins, or -1."""
for pat in patterns:
m = re.search(pat, text, re.IGNORECASE)
if m:
return m.start()
m = re.search(r"\bItem\s+" + str(item_num) + r"\b", text, re.IGNORECASE)
return m.start() if m else -1
def find_item_section_generic(
text: str,
patterns: List[str],
item_num: int,
title_keywords: List[str],
max_chars: int = 120_000,
) -> str:
"""Extract a single Item section from full 10-K text."""
start = _find_section_start(text, patterns, item_num)
if start == -1:
pattern = re.compile(
r"\bItem\s+" + str(item_num)
+ r"\b[.\s]*[^\n]*("
+ "|".join(re.escape(k) for k in title_keywords)
+ r")?",
re.IGNORECASE,
)
match = pattern.search(text)
if not match:
return ""
start = match.start()
next_item = re.search(r"\n\s*Item\s+\d+[A-Z]?\s+", text[start + 100:], re.IGNORECASE)
end = start + 100 + next_item.start() if next_item else min(start + max_chars, len(text))
return text[start:end].strip()
def _extract_item_from_full(
text: str,
patterns: List[str],
item_num: int,
keywords: List[str],
max_chars: int = 60_000,
) -> str:
"""Extract one item section from full 10-K text."""
start = _find_section_start(text, patterns, item_num)
if start < 0:
pat = re.compile(
r"\bItem\s+" + str(item_num) + r"[A-Z]?\b[.\s]*[^\n]*",
re.IGNORECASE,
)
match = pat.search(text)
start = match.start() if match else -1
if start < 0:
return ""
next_item = re.search(r"\n\s*Item\s+\d+[A-Z]?\s+", text[start + 100:], re.IGNORECASE)
end = start + 100 + next_item.start() if next_item else min(start + max_chars, len(text))
return text[start:end].strip()
# ---------------------------------------------------------------------------
# Filing directory helpers
# ---------------------------------------------------------------------------
def _get_edgar_downloader() -> type:
"""Lazy import of ``sec_edgar_downloader.Downloader``."""
from sec_edgar_downloader import Downloader
return Downloader
def find_downloaded_10k_path(download_root: Path, ticker: str) -> Optional[Path]:
"""Locate the most recent 10-K filing directory on disk."""
ticker_upper = ticker.upper()
for base in (download_root / "sec-edgar-filings", download_root):
path_10k = base / ticker_upper / "10-K"
if path_10k.exists():
subdirs = sorted(
[d for d in path_10k.iterdir() if d.is_dir()],
key=lambda x: x.name,
reverse=True,
)
if subdirs:
return subdirs[0]
for base in (download_root / "sec-edgar-filings", download_root):
if not base.exists():
continue
for company_dir in base.iterdir():
if not company_dir.is_dir():
continue
path_10k = company_dir / "10-K"
if path_10k.exists():
subdirs = sorted(
[d for d in path_10k.iterdir() if d.is_dir()],
key=lambda x: x.name,
reverse=True,
)
if subdirs:
return subdirs[0]
return None
def find_all_10k_filing_dirs(download_root: Path, ticker: str) -> List[Path]:
"""Return all 10-K filing directories sorted newest-first."""
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():
return sorted(
[d for d in path_10k.iterdir() if d.is_dir()],
key=lambda x: x.name,
reverse=True,
)
return []
def get_main_10k_text(filing_dir: Path) -> str:
"""Return the longest extracted text from all files in *filing_dir*."""
all_text: List[tuple] = []
for ext in ("*.htm", "*.html", "*.txt"):
for path in filing_dir.rglob(ext):
try:
t = extract_text_from_file(path)
if len(t) > 1000:
all_text.append((path, t))
except Exception:
continue
if not all_text:
return ""
_, main_text = max(all_text, key=lambda x: len(x[1]))
return main_text
# ---------------------------------------------------------------------------
# Cache layer
# ---------------------------------------------------------------------------
def _get_10k_cache_path(ticker: str) -> Path:
"""Path for cached 10-K sections: ``data/TICKER_latest.json``."""
_DATA_DIR.mkdir(parents=True, exist_ok=True)
return _DATA_DIR / f"{ticker.upper()}_latest.json"
def _load_10k_from_cache(ticker: str) -> Optional[Dict[str, str]]:
"""Load cached sections or return ``None`` if absent."""
path = _get_10k_cache_path(ticker)
if not path.exists():
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def _save_10k_to_cache(ticker: str, data: Dict[str, str]) -> None:
"""Persist cleaned 10-K sections to the JSON cache."""
path = _get_10k_cache_path(ticker)
_DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=0)
# ---------------------------------------------------------------------------
# High-level download + extract
# ---------------------------------------------------------------------------
def download_and_extract_all_items(ticker: str, email: str) -> Dict[str, str]:
"""Download latest 10-K, extract Items 1A/3/7/8/9A, clean and cache."""
Downloader = _get_edgar_downloader()
with tempfile.TemporaryDirectory() as tmpdir:
download_root = Path(tmpdir)
dl = Downloader("FQDC-10K-Analyzer", email, str(download_root))
dl.get("10-K", ticker.upper(), limit=1, download_details=True)
filing_dir = find_downloaded_10k_path(download_root, ticker)
if not filing_dir:
raise FileNotFoundError(f"Could not find 10-K for ticker '{ticker}'.")
full_text = get_main_10k_text(filing_dir)
if not full_text:
raise ValueError("Could not extract text from the 10-K.")
item1a = find_item_section_generic(full_text, ITEM1A_PATTERNS, 1, ["Risk", "Factors"], max_chars=80_000)
item3 = _extract_item_from_full(full_text, ITEM3_PATTERNS, 3, ["Legal", "Proceedings"], max_chars=40_000)
item9a = _extract_item_from_full(full_text, ITEM9A_PATTERNS, 9, ["Controls", "Procedures", "Internal"], max_chars=40_000)
start7 = _find_section_start(full_text, ITEM7_PATTERNS, 7)
text_after_7 = full_text[start7:] if start7 >= 0 else full_text
item7 = find_item_section_generic(text_after_7, ITEM7_PATTERNS, 7, ["Management's Discussion", "MD&A", "Analysis"], max_chars=100_000)
if not item7 and text_after_7:
item7 = text_after_7[:120_000]
item8 = _extract_item_from_full(full_text, ITEM8_PATTERNS, 8, ["Financial Statements", "Supplementary Data"], max_chars=200_000)
data: Dict[str, str] = {
"item1a": clean_text_for_llm(item1a or ""),
"item3": clean_text_for_llm(item3 or ""),
"item9a": clean_text_for_llm(item9a or ""),
"item7": clean_text_for_llm(item7 or ""),
"item8": clean_text_for_llm(item8 or ""),
}
_save_10k_to_cache(ticker, data)
return data
def get_10k_sections(ticker: str, email: str) -> tuple[Dict[str, str], str]:
"""Return ``(sections, status)``; *status* is ``'cache'`` or ``'downloaded'``."""
cached = _load_10k_from_cache(ticker)
if cached is not None:
return cached, "cache"
return download_and_extract_all_items(ticker, email), "downloaded"
def download_and_extract_item7_and_1a(ticker: str, email: str) -> tuple[str, str, str]:
"""Fetch 10-K and return ``(full_text, item1a, item7)``."""
sections, _ = get_10k_sections(ticker, email)
return "", sections.get("item1a", "") or "", sections.get("item7", "") or ""
def download_item7_latest_and_3y_ago(
ticker: str,
email: str,
) -> tuple[Optional[str], Optional[str], Optional[str], bool]:
"""Download up to 5 10-Ks; return item1a (latest), item7 latest, item7 3y ago, has_comparison."""
Downloader = _get_edgar_downloader()
with tempfile.TemporaryDirectory() as tmpdir:
download_root = Path(tmpdir)
dl = Downloader("FQDC-10K-Analyzer", email, str(download_root))
dl.get("10-K", ticker.upper(), limit=5, download_details=True)
filing_dirs = find_all_10k_filing_dirs(download_root, ticker)
if not filing_dirs:
raise FileNotFoundError(f"Could not find 10-K for ticker '{ticker}'.")
full_latest = get_main_10k_text(filing_dirs[0])
if not full_latest:
raise ValueError("Could not extract text from the latest 10-K.")
item1a = find_item_section_generic(full_latest, ITEM1A_PATTERNS, 1, ["Risk", "Factors"], max_chars=80_000)
s7 = _find_section_start(full_latest, ITEM7_PATTERNS, 7)
text_after_7 = full_latest[s7:] if s7 >= 0 else full_latest
item7_latest = find_item_section_generic(text_after_7, ITEM7_PATTERNS, 7, ["Management's Discussion", "MD&A", "Analysis"], max_chars=100_000)
if not item7_latest and text_after_7:
item7_latest = smart_chunk(text_after_7[:120_000], max_chars=20_000)
item7_3y_ago: Optional[str] = None
has_comparison = False
if len(filing_dirs) >= 4:
full_3y = get_main_10k_text(filing_dirs[3])
if full_3y:
s7_3y = _find_section_start(full_3y, ITEM7_PATTERNS, 7)
text_3y = full_3y[s7_3y:] if s7_3y >= 0 else full_3y
item7_3y_ago = find_item_section_generic(text_3y, ITEM7_PATTERNS, 7, ["Management's Discussion", "MD&A", "Analysis"], max_chars=100_000)
if not item7_3y_ago and text_3y:
item7_3y_ago = smart_chunk(text_3y[:120_000], max_chars=20_000)
has_comparison = bool(item7_3y_ago)
return item1a or "", item7_latest or "", item7_3y_ago, has_comparison
@@ -0,0 +1,116 @@
"""Sensitivity analysis for DCF valuation.
Provides:
- WACC vs Terminal Growth sensitivity matrix
- Tornado chart data (variable impact ranking)
"""
from typing import Dict, List, Any
from server.services.dcf_engine import excel_style_dcf
def build_sensitivity_matrix(
fcf: float,
total_debt: float,
cash: float,
shares: float,
base_wacc: float,
base_tg: float,
fcf_growth: float,
wacc_steps: int = 6,
tg_steps: int = 5,
wacc_range: float = 0.02,
tg_range: float = 0.01,
) -> Dict[str, Any]:
"""Build a 2-D sensitivity matrix: WACC (rows) x Terminal Growth (cols).
Returns
-------
dict
wacc_values : list[float] row headers (percentages, e.g. 8.0)
tg_values : list[float] column headers (percentages, e.g. 2.5)
matrix : list[list[float | None]] per-share intrinsic values
"""
# Generate evenly-spaced WACC and TG values centred on base
wacc_values = [
round(base_wacc - wacc_range + (2 * wacc_range / max(wacc_steps - 1, 1)) * i, 4)
for i in range(wacc_steps)
]
tg_values = [
round(base_tg - tg_range + (2 * tg_range / max(tg_steps - 1, 1)) * i, 4)
for i in range(tg_steps)
]
matrix: List[List[Any]] = []
for w in wacc_values:
row: List[Any] = []
for tg in tg_values:
if w <= tg or w <= 0 or shares <= 0:
row.append(None)
else:
result = excel_style_dcf(fcf, w, tg, fcf_growth, total_debt, cash, shares)
vps = result.get("value_per_share")
row.append(round(vps, 2) if vps is not None else None)
matrix.append(row)
return {
"wacc_values": [round(w * 100, 2) for w in wacc_values],
"tg_values": [round(tg * 100, 2) for tg in tg_values],
"matrix": matrix,
}
def build_tornado_data(
fcf: float,
wacc: float,
tg: float,
growth: float,
debt: float,
cash: float,
shares: float,
) -> List[Dict[str, Any]]:
"""Compute tornado-chart data by varying each input ±10 %.
Returns a list sorted descending by impact range (high low).
Each entry: {"variable", "low", "high", "base"}.
"""
if shares <= 0:
return []
def _val(f, w, t, g, d, c) -> float | None:
if w <= t or w <= 0:
return None
r = excel_style_dcf(f, w, t, g, d, c, shares)
return r.get("value_per_share")
base_val = _val(fcf, wacc, tg, growth, debt, cash)
if base_val is None:
return []
variables = [
("WACC", lambda sign: _val(fcf, wacc * (1 + sign * 0.10), tg, growth, debt, cash)),
("FCF Growth", lambda sign: _val(fcf, wacc, tg, growth * (1 + sign * 0.10), debt, cash)),
("Terminal Growth", lambda sign: _val(fcf, wacc, tg * (1 + sign * 0.10), growth, debt, cash)),
("Base FCF", lambda sign: _val(fcf * (1 + sign * 0.10), wacc, tg, growth, debt, cash)),
("Total Debt", lambda sign: _val(fcf, wacc, tg, growth, debt * (1 + sign * 0.10), cash)),
("Cash", lambda sign: _val(fcf, wacc, tg, growth, debt, cash * (1 + sign * 0.10))),
]
results: List[Dict[str, Any]] = []
for name, func in variables:
val_up = func(0.10)
val_dn = func(-0.10)
if val_up is None or val_dn is None:
continue
low = round(min(val_up, val_dn), 2)
high = round(max(val_up, val_dn), 2)
results.append({
"variable": name,
"low": low,
"high": high,
"base": round(base_val, 2),
})
results.sort(key=lambda d: d["high"] - d["low"], reverse=True)
return results
@@ -0,0 +1,134 @@
"""Technical analysis service -- compute indicators and detect signals."""
import math
import pandas as pd
import ta
import yfinance as yf
def _safe(val, default=None):
if val is None:
return default
try:
f = float(val)
return default if math.isnan(f) or math.isinf(f) else f
except Exception:
return default
def _series_to_list(s):
return [_safe(v) for v in s.tolist()]
def compute_all_indicators(ticker: str, period: str = "1y") -> dict:
"""Fetch OHLCV from yfinance and compute all TA indicators."""
df = yf.Ticker(ticker).history(period=period)
if df.empty:
return {}
close = df["Close"]
high = df["High"]
low = df["Low"]
volume = df["Volume"]
return {
"dates": df.index.strftime("%Y-%m-%d").tolist(),
"ohlc": {
"open": _series_to_list(df["Open"]),
"high": _series_to_list(high),
"low": _series_to_list(low),
"close": _series_to_list(close),
},
"volume": _series_to_list(volume),
"sma_20": _series_to_list(ta.trend.sma_indicator(close, window=20)),
"sma_50": _series_to_list(ta.trend.sma_indicator(close, window=50)),
"sma_200": _series_to_list(ta.trend.sma_indicator(close, window=200)),
"ema_12": _series_to_list(ta.trend.ema_indicator(close, window=12)),
"ema_26": _series_to_list(ta.trend.ema_indicator(close, window=26)),
"rsi": _series_to_list(ta.momentum.rsi(close, window=14)),
"macd": _series_to_list(ta.trend.macd(close)),
"macd_signal": _series_to_list(ta.trend.macd_signal(close)),
"macd_histogram": _series_to_list(ta.trend.macd_diff(close)),
"bb_upper": _series_to_list(ta.volatility.bollinger_hband(close)),
"bb_lower": _series_to_list(ta.volatility.bollinger_lband(close)),
"bb_middle": _series_to_list(ta.volatility.bollinger_mavg(close)),
"ichimoku_a": _series_to_list(ta.trend.ichimoku_a(high, low)),
"ichimoku_b": _series_to_list(ta.trend.ichimoku_b(high, low)),
"ichimoku_base": _series_to_list(ta.trend.ichimoku_base_line(high, low)),
"ichimoku_conversion": _series_to_list(
ta.trend.ichimoku_conversion_line(high, low)
),
"adx": _series_to_list(ta.trend.adx(high, low, close)),
"signals": detect_signals(df),
}
def detect_signals(df: pd.DataFrame) -> list:
"""Detect Golden Cross, Death Cross, RSI signals."""
signals = []
sma50 = ta.trend.sma_indicator(df["Close"], 50)
sma200 = ta.trend.sma_indicator(df["Close"], 200)
rsi = ta.momentum.rsi(df["Close"], 14)
for i in range(1, len(df)):
if (
pd.notna(sma50.iloc[i])
and pd.notna(sma200.iloc[i])
and pd.notna(sma50.iloc[i - 1])
and pd.notna(sma200.iloc[i - 1])
):
if (
sma50.iloc[i] > sma200.iloc[i]
and sma50.iloc[i - 1] <= sma200.iloc[i - 1]
):
signals.append(
{
"date": df.index[i].strftime("%Y-%m-%d"),
"type": "golden_cross",
"label": "Golden Cross",
}
)
if (
sma50.iloc[i] < sma200.iloc[i]
and sma50.iloc[i - 1] >= sma200.iloc[i - 1]
):
signals.append(
{
"date": df.index[i].strftime("%Y-%m-%d"),
"type": "death_cross",
"label": "Death Cross",
}
)
if pd.notna(rsi.iloc[i]) and pd.notna(rsi.iloc[i - 1]):
if rsi.iloc[i] > 30 and rsi.iloc[i - 1] <= 30:
signals.append(
{
"date": df.index[i].strftime("%Y-%m-%d"),
"type": "rsi_oversold_bounce",
"label": "RSI Oversold Bounce",
}
)
if rsi.iloc[i] > 70 and rsi.iloc[i - 1] <= 70:
signals.append(
{
"date": df.index[i].strftime("%Y-%m-%d"),
"type": "rsi_overbought",
"label": "RSI Overbought",
}
)
return signals
def compute_fibonacci_levels(high_52w: float, recent_low: float) -> dict:
"""Compute Fibonacci retracement levels from 52-week high and recent low."""
diff = high_52w - recent_low
return {
"high": high_52w,
"low": recent_low,
"level_236": recent_low + diff * 0.236,
"level_382": recent_low + diff * 0.382,
"level_500": recent_low + diff * 0.500,
"level_618": recent_low + diff * 0.618,
"level_786": recent_low + diff * 0.786,
}
@@ -0,0 +1,132 @@
"""Text cleaning and chunking utilities for LLM payloads.
Provides aggressive HTML-stripping, whitespace normalisation, and
intelligent splitting of long text into sequential chunks that avoid
cutting mid-sentence when possible.
"""
import re
from typing import List
from bs4 import BeautifulSoup
def clean_text_for_llm(html_content: str) -> str:
"""Strip HTML, collapse whitespace, and remove non-ASCII for LLM input.
Removes ``<table>``, ``<img>``, ``<style>``, ``<script>``, ``<svg>``,
and ``<math>`` elements before extracting text. Drops page-number-only
lines and other layout artefacts.
Parameters
----------
html_content:
Raw HTML (or already-plain text with residual tags).
Returns
-------
str
Clean, single-line-ish text suitable for an LLM prompt.
"""
if not html_content or not html_content.strip():
return ""
try:
soup = BeautifulSoup(html_content, "lxml")
for tag in soup.find_all(["table", "img", "style", "script", "svg", "math"]):
tag.decompose()
text = soup.get_text(separator=" ")
except Exception:
text = re.sub(r"<[^>]+>", " ", html_content)
text = re.sub(r"\s+", " ", text)
text = " ".join(text.split())
text = re.sub(r"[^\x20-\x7E\n]", " ", text)
text = re.sub(r"\s+", " ", text).strip()
lines: List[str] = []
for line in text.split("\n"):
line = line.strip()
if not line:
continue
if re.fullmatch(r"\d+", line) or re.fullmatch(r"[\.\-\s\-]+", line):
continue
if re.match(r"^(page\s+\d+|\d+)\s*$", line, re.IGNORECASE) and len(line) < 20:
continue
lines.append(line)
result = " ".join(lines)
result = re.sub(r"\s+", " ", result).strip()
return result
def smart_chunk(
section: str,
max_chars: int = 10_000,
head_ratio: float = 0.5,
) -> str:
"""Truncate *section* to *max_chars* keeping head and tail portions.
When the text exceeds the limit the middle is replaced with a brief
``[ ... middle omitted ... ]`` marker. Approximately
``head_ratio * max_chars`` characters come from the start and the
remainder from the end.
Parameters
----------
section:
Full text to be trimmed.
max_chars:
Hard character budget (default 10 000 ~= 2.5k tokens).
head_ratio:
Fraction of the budget allocated to the leading portion.
Returns
-------
str
Text guaranteed to be at most *max_chars* characters long.
"""
if not section or len(section) <= max_chars:
return section
head_size = int(max_chars * head_ratio)
tail_size = max_chars - head_size - 100
return section[:head_size] + " [ ... middle omitted ... ] " + section[-tail_size:]
def _split_into_chunks(
text: str,
max_chars: int = 22_000,
min_chunk: int = 5_000,
) -> List[str]:
"""Split *text* into sequential chunks without cutting mid-sentence.
Prefers breaking at paragraph boundaries (double newlines). Each chunk
is at most *max_chars* characters; the algorithm avoids creating a
trailing fragment shorter than *min_chunk* unless it is the only chunk.
Parameters
----------
text:
The document to split.
max_chars:
Maximum characters per chunk.
min_chunk:
Minimum look-back distance when searching for a break point.
Returns
-------
List[str]
Non-empty stripped chunks in document order.
"""
if not text or len(text) <= max_chars:
return [text] if text and text.strip() else []
chunks: List[str] = []
start = 0
while start < len(text):
end = min(start + max_chars, len(text))
if end < len(text):
break_at = text.rfind("\n\n", start, end + 1)
if break_at > start + min_chunk:
end = break_at + 2
chunks.append(text[start:end].strip())
start = end
return [c for c in chunks if c]
+88
View File
@@ -0,0 +1,88 @@
"""Numeric safety utilities for the ATLAS Terminal backend.
Provides safe type-coercion helpers used across all services to handle
None, NaN, and non-numeric values gracefully without raising exceptions.
"""
from typing import Optional
import pandas as pd
def _safe_float(x: object) -> Optional[float]:
"""Convert *x* to ``float``, returning ``None`` for unconvertible values.
Handles ``None``, ``NaN`` (both Python ``float('nan')`` and pandas
``pd.NA``), and arbitrary objects whose ``float()`` conversion fails.
Parameters
----------
x:
Any value that might be numeric.
Returns
-------
Optional[float]
The float representation, or ``None`` if conversion is impossible.
"""
if x is None or (isinstance(x, float) and (x != x or pd.isna(x))):
return None
try:
return float(x)
except (TypeError, ValueError):
return None
def _na(x: object) -> object:
"""Return the string ``'N/A'`` for ``None``/``NaN``, otherwise *x* unchanged.
Useful when building display-ready dictionaries or DataFrames where
missing numeric values should appear as a human-readable sentinel.
Parameters
----------
x:
Any value.
Returns
-------
object
``'N/A'`` when *x* is ``None`` or ``NaN``; *x* otherwise.
"""
if x is None or (isinstance(x, float) and (pd.isna(x) or x != x)):
return "N/A"
return x
def _format_shares_display(shares: Optional[float]) -> str:
"""Format a share count for human-friendly display.
Examples
--------
>>> _format_shares_display(15_420_000_000)
'15.42B Shares'
>>> _format_shares_display(1_200_000)
'1.20M Shares'
>>> _format_shares_display(None)
'N/A'
Parameters
----------
shares:
Raw share count (absolute number, not in millions/billions).
Returns
-------
str
A concise string such as ``'15.42B Shares'`` or ``'N/A'``.
"""
if shares is None or shares <= 0:
return "N/A"
s = float(shares)
if s >= 1e9:
return f"{s / 1e9:.2f}B Shares"
if s >= 1e6:
return f"{s / 1e6:.2f}M Shares"
if s >= 1e3:
return f"{s / 1e3:.2f}K Shares"
return f"{s:.0f} Shares"
+122
View File
@@ -0,0 +1,122 @@
"""Ticker formatting, market inference, and company/sector reference data.
Centralises the mapping logic that converts bare ticker symbols into
Yahoo Finance-compatible identifiers with the correct market suffix,
and provides the static lookup tables for companies and sectors.
"""
from typing import List, Tuple
# ---------------------------------------------------------------------------
# Company reference data
# ---------------------------------------------------------------------------
COMPANY_LIST: List[Tuple[str, str]] = [
("NVIDIA Corporation", "NVDA"), ("Apple Inc.", "AAPL"), ("Microsoft Corporation", "MSFT"),
("Amazon.com Inc.", "AMZN"), ("Alphabet Inc.", "GOOGL"), ("Meta Platforms Inc.", "META"),
("AMD", "AMD"), ("Intel Corporation", "INTC"), ("Qualcomm Inc.", "QCOM"), ("Tesla Inc.", "TSLA"),
("Berkshire Hathaway", "BRK.B"), ("JPMorgan Chase", "JPM"), ("Visa Inc.", "V"),
("UnitedHealth", "UNH"), ("Procter & Gamble", "PG"), ("Exxon Mobil", "XOM"),
("Johnson & Johnson", "JNJ"), ("Mastercard", "MA"), ("Chevron", "CVX"),
("Home Depot", "HD"), ("Merck", "MRK"), ("AbbVie", "ABBV"), ("Costco", "COST"),
("PepsiCo", "PEP"), ("Coca-Cola", "KO"), ("Pfizer", "PFE"), ("Walmart", "WMT"),
("Netflix", "NFLX"), ("Adobe", "ADBE"), ("Salesforce", "CRM"), ("Comcast", "CMCSA"),
("Cisco", "CSCO"), ("Oracle", "ORCL"), ("American Express", "AXP"),
("Bank of America", "BAC"), ("Wells Fargo", "WFC"), ("Verizon", "VZ"),
("AT&T", "T"), ("Walt Disney", "DIS"), ("Nike", "NKE"), ("McDonald's", "MCD"),
("Starbucks", "SBUX"), ("Goldman Sachs", "GS"), ("Morgan Stanley", "MS"),
("Target", "TGT"), ("Boeing", "BA"), ("IBM", "IBM"),
]
COMPANY_OPTIONS: List[str] = [f"{t} - {n}" for n, t in COMPANY_LIST]
"""Pre-formatted ``'TICKER - Company Name'`` strings for dropdowns."""
COMPANY_TICKER_MAP: dict[str, str] = {t: n for n, t in COMPANY_LIST}
"""Mapping from ticker symbol to full company name."""
MARKET_OPTIONS: List[str] = [
"US (S&P/Dow/Nasdaq)",
"South Korea (KOSPI/KOSDAQ)",
"Japan (Nikkei)",
"UK (LSE)",
]
# ---------------------------------------------------------------------------
# Sector / industry peer groups (top-down analysis)
# ---------------------------------------------------------------------------
SECTORS: dict[str, List[str]] = {
"Semiconductors & Hardware": ["NVDA", "AMD", "INTC", "TSM", "AVGO"],
"Software & Cloud": ["MSFT", "ADBE", "CRM", "PANW", "CRWD"],
"Consumer Retail": ["AMZN", "SBUX", "MCD", "WMT", "HD"],
"Financial Services": ["JPM", "BAC", "GS", "MS", "V"],
"Healthcare": ["LLY", "UNH", "JNJ", "ABBV", "MRK"],
}
# ---------------------------------------------------------------------------
# Ticker helpers
# ---------------------------------------------------------------------------
def get_global_ticker(ticker: str, market: str) -> str:
"""Append the correct Yahoo Finance suffix based on the selected market.
US tickers are returned as-is. If the ticker already carries a known
suffix (``.KS``, ``.KQ``, ``.T``, ``.L``) it is returned unchanged
regardless of the *market* argument.
Parameters
----------
ticker:
Raw ticker string entered by the user.
market:
One of the values in :data:`MARKET_OPTIONS`.
Returns
-------
str
The ticker with an appropriate suffix (or unchanged for US).
"""
if not (ticker or "").strip():
return (ticker or "").strip()
t = (ticker or "").strip()
if t.upper().endswith((".KS", ".KQ", ".T", ".L")):
return t
m = (market or "").strip()
if "US" in m or not m:
return t
if "Korea" in m or "KOSPI" in m or "KOSDAQ" in m:
return t + ".KS"
if "Japan" in m or "Nikkei" in m:
return t + ".T"
if "UK" in m or "LSE" in m:
return t + ".L"
return t
def infer_market_from_ticker(ticker: str) -> str:
"""Guess the market label from a ticker's suffix.
Useful when the caller has a fully-qualified ticker (e.g. ``005930.KS``)
but no explicit market selection.
Parameters
----------
ticker:
A ticker string that may include a market suffix.
Returns
-------
str
The best-matching entry from :data:`MARKET_OPTIONS`.
"""
if not (ticker or "").strip():
return MARKET_OPTIONS[0]
t = (ticker or "").strip().upper()
if t.endswith(".KS") or t.endswith(".KQ"):
return "South Korea (KOSPI/KOSDAQ)"
if t.endswith(".T"):
return "Japan (Nikkei)"
if t.endswith(".L"):
return "UK (LSE)"
return "US (S&P/Dow/Nasdaq)"
@@ -0,0 +1,38 @@
-- Portfolio positions table
CREATE TABLE IF NOT EXISTS positions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
ticker TEXT NOT NULL,
company_name TEXT,
quantity DECIMAL(15,6) NOT NULL,
avg_price DECIMAL(15,4) NOT NULL,
currency TEXT DEFAULT 'USD',
source TEXT DEFAULT 'manual',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Watchlist table
CREATE TABLE IF NOT EXISTS watchlist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
ticker TEXT NOT NULL,
added_at TIMESTAMPTZ DEFAULT NOW()
);
-- Analysis cache table
CREATE TABLE IF NOT EXISTS analysis_cache (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticker TEXT NOT NULL,
analysis_type TEXT NOT NULL,
data JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
-- Indexes
CREATE INDEX idx_positions_user ON positions(user_id);
CREATE INDEX idx_positions_ticker ON positions(ticker);
CREATE INDEX idx_watchlist_user ON watchlist(user_id);
CREATE INDEX idx_cache_ticker_type ON analysis_cache(ticker, analysis_type);
CREATE INDEX idx_cache_expires ON analysis_cache(expires_at);
View File
+49
View File
@@ -0,0 +1,49 @@
"""Shared pytest fixtures for ATLAS Terminal test suite."""
import sys
from pathlib import Path
import pytest
# Ensure the project root is on sys.path so `server.*` imports work.
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
@pytest.fixture
def sample_ticker():
"""A well-known US ticker for integration-style tests."""
return "AAPL"
@pytest.fixture
def sample_fcf_inputs():
"""Reasonable DCF inputs for unit-testing valuation functions."""
return {
"fcf": 100_000_000_000, # $100B trailing FCF
"wacc": 0.10, # 10%
"terminal_growth": 0.025, # 2.5%
"fcf_growth": 0.08, # 8%
"total_debt": 110_000_000_000, # $110B
"cash": 60_000_000_000, # $60B
"shares": 15_500_000_000, # 15.5B shares
}
@pytest.fixture
def sample_balance_sheet_values():
"""Simplified balance-sheet figures for Altman Z / DuPont tests."""
return {
"current_assets": 150_000_000_000,
"current_liabilities": 120_000_000_000,
"total_assets": 350_000_000_000,
"retained_earnings": 50_000_000_000,
"total_liabilities": 290_000_000_000,
"total_equity": 60_000_000_000,
"ebit": 120_000_000_000,
"sales": 400_000_000_000,
"market_cap": 2_800_000_000_000,
"net_income": 95_000_000_000,
"revenue": 400_000_000_000,
}
+198
View File
@@ -0,0 +1,198 @@
"""Tests for server.services.dcf_engine -- DCF formula accuracy."""
import pytest
from server.services.dcf_engine import (
dcf_intrinsic_value,
dcf_10y_2stage,
excel_style_dcf,
_damodaran_wacc_for_sector,
DAMODARAN_WACC,
)
# ---------------------------------------------------------------------------
# dcf_intrinsic_value (5-year single-stage)
# ---------------------------------------------------------------------------
class TestDCFIntrinsicValue:
"""Verify 5-year single-stage DCF maths."""
def test_basic_positive_fcf(self):
"""Known-good manual calculation with simple inputs."""
result = dcf_intrinsic_value(
fcf=100, wacc=0.10, terminal_growth=0.02, fcf_growth=0.05, years=5,
)
# Manually:
# Y1: 100/(1.10)^1, Y2: 105/(1.10)^2, ... + terminal value
assert result > 0
# Rough sanity: terminal value dominates, so EV > 5 * FCF
assert result > 500
def test_zero_fcf_returns_zero(self):
assert dcf_intrinsic_value(0, 0.10, 0.02, 0.05) == 0.0
def test_negative_fcf_returns_zero(self):
assert dcf_intrinsic_value(-100, 0.10, 0.02, 0.05) == 0.0
def test_none_fcf_returns_zero(self):
assert dcf_intrinsic_value(None, 0.10, 0.02, 0.05) == 0.0
def test_wacc_less_than_terminal_growth_returns_zero(self):
"""Gordon growth model breaks if WACC <= g."""
assert dcf_intrinsic_value(100, 0.02, 0.05, 0.05) == 0.0
def test_wacc_equal_terminal_growth_returns_zero(self):
assert dcf_intrinsic_value(100, 0.05, 0.05, 0.05) == 0.0
def test_zero_wacc_returns_zero(self):
assert dcf_intrinsic_value(100, 0, 0.02, 0.05) == 0.0
def test_higher_growth_higher_value(self):
"""Increasing FCF growth should increase EV."""
low = dcf_intrinsic_value(100, 0.10, 0.02, 0.03)
high = dcf_intrinsic_value(100, 0.10, 0.02, 0.10)
assert high > low
def test_higher_wacc_lower_value(self):
"""Increasing WACC should decrease EV (more discounting)."""
low_wacc = dcf_intrinsic_value(100, 0.08, 0.02, 0.05)
high_wacc = dcf_intrinsic_value(100, 0.15, 0.02, 0.05)
assert low_wacc > high_wacc
def test_reproducibility(self):
"""Same inputs always yield same result (deterministic)."""
a = dcf_intrinsic_value(1000, 0.10, 0.025, 0.08, years=5)
b = dcf_intrinsic_value(1000, 0.10, 0.025, 0.08, years=5)
assert a == b
def test_manual_calculation(self):
"""Hand-verify a simple 2-year DCF with no growth."""
# FCF=100, growth=0%, WACC=10%, terminal_growth=0%, years=2
# Y1 PV = 100/1.10 = 90.909...
# Y2 PV = 100/1.21 = 82.644...
# Terminal FCF after Y2 = 100 (no growth applied beyond projection)
# TV = 100*(1+0)/(0.10-0) = 1000
# PV of TV = 1000/1.21 = 826.446...
# Total = 90.909 + 82.644 + 826.446 = ~1000
result = dcf_intrinsic_value(100, 0.10, 0.0, 0.0, years=2)
assert result == pytest.approx(1000.0, rel=0.01)
# ---------------------------------------------------------------------------
# dcf_10y_2stage
# ---------------------------------------------------------------------------
class TestDCF10y2Stage:
"""Verify 10-year two-stage DCF."""
def test_positive_result(self):
result = dcf_10y_2stage(fcf=100, wacc=0.10, term_growth=0.02, fcf_growth=0.08)
assert result > 0
def test_zero_fcf(self):
assert dcf_10y_2stage(0, 0.10, 0.02, 0.08) == 0.0
def test_none_fcf(self):
assert dcf_10y_2stage(None, 0.10, 0.02, 0.08) == 0.0
def test_wacc_leq_terminal(self):
assert dcf_10y_2stage(100, 0.02, 0.03, 0.08) == 0.0
def test_two_stage_higher_than_single_with_high_growth(self):
"""With high near-term growth, 10y 2-stage should capture more value
than a 5-year model because it has more high-growth years."""
two_stage = dcf_10y_2stage(100, 0.10, 0.02, 0.15)
single = dcf_intrinsic_value(100, 0.10, 0.02, 0.15, years=5)
# 10y model projects more years of above-terminal growth
assert two_stage > single * 0.8 # at least in the same ballpark
# ---------------------------------------------------------------------------
# excel_style_dcf
# ---------------------------------------------------------------------------
class TestExcelStyleDCF:
"""Verify EV -> Equity -> per-share bridge."""
def test_basic_output_keys(self, sample_fcf_inputs):
result = excel_style_dcf(
fcf_base=sample_fcf_inputs["fcf"],
wacc=sample_fcf_inputs["wacc"],
term_growth=sample_fcf_inputs["terminal_growth"],
fcf_growth=sample_fcf_inputs["fcf_growth"],
total_debt=sample_fcf_inputs["total_debt"],
cash=sample_fcf_inputs["cash"],
shares=sample_fcf_inputs["shares"],
)
assert "ev" in result
assert "equity_value" in result
assert "value_per_share" in result
assert "shares" in result
def test_equity_equals_ev_minus_debt_plus_cash(self, sample_fcf_inputs):
result = excel_style_dcf(
fcf_base=sample_fcf_inputs["fcf"],
wacc=sample_fcf_inputs["wacc"],
term_growth=sample_fcf_inputs["terminal_growth"],
fcf_growth=sample_fcf_inputs["fcf_growth"],
total_debt=sample_fcf_inputs["total_debt"],
cash=sample_fcf_inputs["cash"],
shares=sample_fcf_inputs["shares"],
)
expected_equity = result["ev"] - sample_fcf_inputs["total_debt"] + sample_fcf_inputs["cash"]
assert result["equity_value"] == pytest.approx(expected_equity, rel=1e-9)
def test_value_per_share_equals_equity_div_shares(self, sample_fcf_inputs):
result = excel_style_dcf(
fcf_base=sample_fcf_inputs["fcf"],
wacc=sample_fcf_inputs["wacc"],
term_growth=sample_fcf_inputs["terminal_growth"],
fcf_growth=sample_fcf_inputs["fcf_growth"],
total_debt=sample_fcf_inputs["total_debt"],
cash=sample_fcf_inputs["cash"],
shares=sample_fcf_inputs["shares"],
)
expected_vps = result["equity_value"] / sample_fcf_inputs["shares"]
assert result["value_per_share"] == pytest.approx(expected_vps, rel=1e-9)
def test_zero_shares_returns_none_vps(self):
result = excel_style_dcf(100, 0.10, 0.02, 0.08, 50, 20, 0)
assert result["value_per_share"] is None
def test_none_shares_returns_none_vps(self):
result = excel_style_dcf(100, 0.10, 0.02, 0.08, 50, 20, None)
assert result["value_per_share"] is None
# ---------------------------------------------------------------------------
# _damodaran_wacc_for_sector
# ---------------------------------------------------------------------------
class TestDamodaranWACC:
"""Test sector -> WACC mapping."""
def test_software_sector(self):
assert _damodaran_wacc_for_sector("Software") == DAMODARAN_WACC["Software"]
def test_technology_sector(self):
assert _damodaran_wacc_for_sector("Technology") == DAMODARAN_WACC["Software"]
def test_healthcare(self):
assert _damodaran_wacc_for_sector("Healthcare") == DAMODARAN_WACC["Healthcare"]
def test_utilities(self):
assert _damodaran_wacc_for_sector("Utilities") == DAMODARAN_WACC["Utilities"]
def test_unknown_sector_default(self):
assert _damodaran_wacc_for_sector("Alien Technology") == 8.0
def test_empty_string_default(self):
assert _damodaran_wacc_for_sector("") == 8.0
def test_none_default(self):
assert _damodaran_wacc_for_sector(None) == 8.0
def test_case_insensitive(self):
assert _damodaran_wacc_for_sector("software") == DAMODARAN_WACC["Software"]
assert _damodaran_wacc_for_sector("FINANCIAL SERVICES") == DAMODARAN_WACC["Financials"]
@@ -0,0 +1,210 @@
"""Tests for server.services.financial_metrics -- DuPont, Altman Z, radar normalisation."""
import pytest
from server.services.financial_metrics import _radar_norm
# ---------------------------------------------------------------------------
# _radar_norm
# ---------------------------------------------------------------------------
class TestRadarNorm:
"""Verify radar chart normalisation to 0-100 range."""
def test_all_none_returns_defaults(self):
result = _radar_norm(None, None, None, None, None)
assert result == [50, 50, 50, 50, 50]
def test_returns_five_values(self):
result = _radar_norm(15.0, 1.5, 1.0, 2.0, 10.0)
assert len(result) == 5
def test_all_values_in_range(self):
result = _radar_norm(30.0, 2.5, 1.5, 2.5, 25.0)
for v in result:
assert 0 <= v <= 100
def test_extreme_high_values_capped_at_100(self):
result = _radar_norm(100.0, 10.0, 5.0, 10.0, 100.0)
for v in result:
assert v <= 100
def test_extreme_low_values_floored_at_0(self):
result = _radar_norm(-50.0, -1.0, -1.0, -1.0, -50.0)
for v in result:
assert v >= 0
def test_roe_normalisation(self):
# n_roe: (x + 10) / 40 * 100
# ROE = 30% -> (30+10)/40*100 = 100
result = _radar_norm(30.0, None, None, None, None)
assert result[0] == 100.0
def test_roe_negative(self):
# ROE = -10% -> (-10+10)/40*100 = 0
result = _radar_norm(-10.0, None, None, None, None)
assert result[0] == 0.0
def test_current_ratio_normalisation(self):
# n_cr: x / 3 * 100
# CR = 1.5 -> 1.5/3*100 = 50
result = _radar_norm(None, 1.5, None, None, None)
assert result[1] == 50.0
def test_asset_turnover_normalisation(self):
# n_at: x * 50
# AT = 1.0 -> 50
result = _radar_norm(None, None, 1.0, None, None)
assert result[2] == 50.0
def test_equity_mult_normalisation(self):
# n_em: (x - 0.5) / 2.5 * 100
# EM = 2.0 -> (2.0-0.5)/2.5*100 = 60
result = _radar_norm(None, None, None, 2.0, None)
assert result[3] == 60.0
def test_yoy_normalisation(self):
# n_yoy: (x + 20) / 50 * 100
# YoY = 10% -> (10+20)/50*100 = 60
result = _radar_norm(None, None, None, None, 10.0)
assert result[4] == 60.0
# ---------------------------------------------------------------------------
# Altman Z-Score formula verification (unit-level)
# ---------------------------------------------------------------------------
class TestAltmanZFormula:
"""Verify the Altman Z-Score formula independently of data fetching."""
def test_altman_z_manual_calculation(self, sample_balance_sheet_values):
"""Hand-compute Altman Z and check the formula:
Z = 1.2*A + 1.4*B + 3.3*C + 0.6*D + 1.0*E
where:
A = Working Capital / Total Assets
B = Retained Earnings / Total Assets
C = EBIT / Total Assets
D = Market Cap / Total Liabilities
E = Sales / Total Assets
"""
v = sample_balance_sheet_values
ta = v["total_assets"]
a = (v["current_assets"] - v["current_liabilities"]) / ta
b = v["retained_earnings"] / ta
c = v["ebit"] / ta
d = v["market_cap"] / v["total_liabilities"]
e = v["sales"] / ta
z = 1.2 * a + 1.4 * b + 3.3 * c + 0.6 * d + 1.0 * e
# With the sample values:
# A = (150B-120B)/350B = 30/350 = 0.08571
# B = 50B/350B = 0.14286
# C = 120B/350B = 0.34286
# D = 2800B/290B = 9.65517
# E = 400B/350B = 1.14286
assert z == pytest.approx(
1.2 * 0.08571 + 1.4 * 0.14286 + 3.3 * 0.34286 + 0.6 * 9.65517 + 1.0 * 1.14286,
rel=0.01,
)
# Z > 2.99 is "safe zone"
assert z > 2.99
def test_altman_z_distress_zone(self):
"""A company with poor financials should score below 1.81."""
ta = 100
a = -20 / ta # negative working capital
b = -10 / ta # negative retained earnings
c = -5 / ta # negative EBIT (loss)
d = 10 / 90 # low market cap vs liabilities
e = 50 / ta # low sales/assets
z = 1.2 * a + 1.4 * b + 3.3 * c + 0.6 * d + 1.0 * e
assert z < 1.81
# ---------------------------------------------------------------------------
# DuPont 3-step formula verification (unit-level)
# ---------------------------------------------------------------------------
class TestDuPontFormula:
"""Verify DuPont decomposition: ROE = NPM * Asset Turnover * Equity Multiplier."""
def test_dupont_identity(self, sample_balance_sheet_values):
v = sample_balance_sheet_values
npm = v["net_income"] / v["revenue"] # Net Profit Margin
asset_turnover = v["revenue"] / v["total_assets"] # Asset Turnover
equity_mult = v["total_assets"] / v["total_equity"] # Equity Multiplier
roe_dupont = npm * asset_turnover * equity_mult
roe_direct = v["net_income"] / v["total_equity"]
assert roe_dupont == pytest.approx(roe_direct, rel=1e-9)
def test_dupont_components_reasonable(self, sample_balance_sheet_values):
v = sample_balance_sheet_values
npm = v["net_income"] / v["revenue"]
at = v["revenue"] / v["total_assets"]
em = v["total_assets"] / v["total_equity"]
assert 0 < npm < 1 # Profit margin should be between 0% and 100%
assert at > 0 # Asset turnover should be positive
assert em >= 1 # Equity multiplier is always >= 1 for solvent firms
# ---------------------------------------------------------------------------
# Piotroski F-Score criteria (unit-level check)
# ---------------------------------------------------------------------------
class TestPiotroskiFScoreCriteria:
"""Verify individual F-Score criteria logic."""
def test_positive_net_income_scores_1(self):
assert (1 if 95_000_000_000 > 0 else 0) == 1
def test_negative_net_income_scores_0(self):
assert (1 if -5_000_000 > 0 else 0) == 0
def test_positive_roa_change_scores_1(self):
roa_curr = 0.27
roa_prev = 0.25
assert (1 if roa_curr > roa_prev else 0) == 1
def test_positive_ocf_scores_1(self):
assert (1 if 100_000_000_000 > 0 else 0) == 1
def test_ocf_gt_net_income_scores_1(self):
ocf = 120_000_000_000
ni = 95_000_000_000
assert (1 if ocf > ni else 0) == 1
def test_leverage_decrease_scores_1(self):
debt_to_assets_curr = 0.40
debt_to_assets_prev = 0.45
assert (1 if debt_to_assets_curr < debt_to_assets_prev else 0) == 1
def test_current_ratio_increase_scores_1(self):
cr_curr = 1.35
cr_prev = 1.20
assert (1 if cr_curr > cr_prev else 0) == 1
def test_no_dilution_scores_1(self):
shares_curr = 15_500_000_000
shares_prev = 15_800_000_000
assert (1 if shares_curr <= shares_prev else 0) == 1
def test_gross_margin_increase_scores_1(self):
gm_curr = 0.45
gm_prev = 0.43
assert (1 if gm_curr > gm_prev else 0) == 1
def test_asset_turnover_increase_scores_1(self):
at_curr = 1.15
at_prev = 1.10
assert (1 if at_curr > at_prev else 0) == 1
def test_max_fscore_is_9(self):
"""All 9 criteria passing should sum to 9."""
criteria = [1, 1, 1, 1, 1, 1, 1, 1, 1]
assert sum(criteria) == 9
+143
View File
@@ -0,0 +1,143 @@
"""Tests for server.utils.safe_float -- edge cases and boundary conditions."""
import math
import pandas as pd
import pytest
from server.utils.safe_float import _safe_float, _na, _format_shares_display
# ---------------------------------------------------------------------------
# _safe_float
# ---------------------------------------------------------------------------
class TestSafeFloat:
"""Exhaustive edge-case coverage for _safe_float."""
def test_none_returns_none(self):
assert _safe_float(None) is None
def test_nan_float_returns_none(self):
assert _safe_float(float("nan")) is None
def test_math_nan_returns_none(self):
assert _safe_float(math.nan) is None
def test_pandas_na_returns_none(self):
assert _safe_float(pd.NA) is None
def test_pandas_nat_returns_none(self):
assert _safe_float(pd.NaT) is None
def test_int_converts(self):
assert _safe_float(42) == 42.0
def test_float_passthrough(self):
assert _safe_float(3.14) == 3.14
def test_negative_float(self):
assert _safe_float(-9.99) == -9.99
def test_zero(self):
assert _safe_float(0) == 0.0
def test_string_numeric(self):
assert _safe_float("123.45") == 123.45
def test_string_negative(self):
assert _safe_float("-7.5") == -7.5
def test_string_non_numeric_returns_none(self):
assert _safe_float("hello") is None
def test_empty_string_returns_none(self):
assert _safe_float("") is None
def test_bool_true(self):
# bool is subclass of int; float(True) == 1.0
assert _safe_float(True) == 1.0
def test_bool_false(self):
assert _safe_float(False) == 0.0
def test_inf_positive(self):
result = _safe_float(float("inf"))
assert result == float("inf")
def test_inf_negative(self):
result = _safe_float(float("-inf"))
assert result == float("-inf")
def test_large_number(self):
assert _safe_float(1e18) == 1e18
def test_very_small_number(self):
assert _safe_float(1e-15) == pytest.approx(1e-15)
def test_object_returns_none(self):
assert _safe_float(object()) is None
def test_list_returns_none(self):
assert _safe_float([1, 2, 3]) is None
def test_dict_returns_none(self):
assert _safe_float({"a": 1}) is None
# ---------------------------------------------------------------------------
# _na
# ---------------------------------------------------------------------------
class TestNa:
"""Tests for the _na display helper."""
def test_none_returns_na_string(self):
assert _na(None) == "N/A"
def test_nan_returns_na_string(self):
assert _na(float("nan")) == "N/A"
def test_valid_float_passthrough(self):
assert _na(3.14) == 3.14
def test_zero_passthrough(self):
assert _na(0) == 0
def test_string_passthrough(self):
assert _na("hello") == "hello"
# ---------------------------------------------------------------------------
# _format_shares_display
# ---------------------------------------------------------------------------
class TestFormatSharesDisplay:
"""Tests for the _format_shares_display utility."""
def test_none_returns_na(self):
assert _format_shares_display(None) == "N/A"
def test_zero_returns_na(self):
assert _format_shares_display(0) == "N/A"
def test_negative_returns_na(self):
assert _format_shares_display(-100) == "N/A"
def test_billions(self):
assert _format_shares_display(15_420_000_000) == "15.42B Shares"
def test_millions(self):
assert _format_shares_display(1_200_000) == "1.20M Shares"
def test_thousands(self):
assert _format_shares_display(5_500) == "5.50K Shares"
def test_small_number(self):
assert _format_shares_display(42) == "42 Shares"
def test_exact_billion(self):
assert _format_shares_display(1_000_000_000) == "1.00B Shares"
def test_exact_million(self):
assert _format_shares_display(1_000_000) == "1.00M Shares"
+88
View File
@@ -0,0 +1,88 @@
"""
Analyst estimates earnings & revenue forecasts, consensus targets.
"""
import streamlit as st
import pandas as pd
try:
import yfinance as yf
except ImportError:
yf = None
@st.cache_data(ttl=600)
def get_analyst_estimates(ticker: str) -> dict:
"""Fetch analyst earnings and revenue estimates from yfinance."""
if not yf or not ticker:
return {}
try:
t = yf.Ticker(ticker)
result = {}
# Earnings estimates
ee = getattr(t, "earnings_estimate", None)
if ee is not None and not ee.empty:
result["earnings_estimate"] = ee
# Revenue estimates
re = getattr(t, "revenue_estimate", None)
if re is not None and not re.empty:
result["revenue_estimate"] = re
# EPS trend
et = getattr(t, "eps_trend", None)
if et is not None and not et.empty:
result["eps_trend"] = et
# Earnings history
eh = getattr(t, "earnings_history", None)
if eh is not None and not eh.empty:
result["earnings_history"] = eh
# Growth estimates
ge = getattr(t, "growth_estimates", None)
if ge is not None and not ge.empty:
result["growth_estimates"] = ge
# Price targets
info = t.info or {}
result["targets"] = {
"current": info.get("currentPrice") or info.get("regularMarketPrice"),
"mean": info.get("targetMeanPrice"),
"high": info.get("targetHighPrice"),
"low": info.get("targetLowPrice"),
"median": info.get("targetMedianPrice"),
"recommendation": info.get("recommendationKey", "N/A"),
"num_analysts": info.get("numberOfAnalystOpinions"),
}
return result
except Exception:
return {}
@st.cache_data(ttl=600)
def get_earnings_dates(ticker: str) -> pd.DataFrame:
"""Fetch historical and upcoming earnings dates with surprise data."""
if not yf or not ticker:
return pd.DataFrame()
try:
t = yf.Ticker(ticker)
dates = t.earnings_dates
if dates is not None and not dates.empty:
return dates.head(12)
except Exception:
pass
return pd.DataFrame()
def format_estimate_table(df: pd.DataFrame) -> pd.DataFrame:
"""Format estimate DataFrame for display with proper number formatting."""
if df is None or df.empty:
return pd.DataFrame()
display = df.copy()
for col in display.columns:
display[col] = display[col].apply(
lambda v: f"{v:,.2f}" if isinstance(v, (int, float)) and v == v else "N/A"
)
return display

Some files were not shown because too many files have changed in this diff Show More