mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-07 07:47:45 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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가 참조한다.
|
||||
|
||||
---
|
||||
@@ -1,223 +0,0 @@
|
||||
# FQDC 프로젝트 심층 분석 보고서
|
||||
|
||||
> All-in-One Financial Analysis Dashboard — 정성·정량 하이브리드 금융 분석 대시보드
|
||||
|
||||
---
|
||||
|
||||
## 1. 프로젝트 정체성 및 비전
|
||||
|
||||
### 1.1 목적
|
||||
- **문제:** 주식 리서치의 분산 — 200페이지급 10-K 공시, 별도 스프레드시트 DCF, 여러 도구에 흩어진 동종사 비교.
|
||||
- **해결:** **단일 워크플로우**에서 공시 → 인사이트 → 밸류에이션 → 동종사 비교까지 한 번에 수행.
|
||||
|
||||
### 1.2 타겟
|
||||
- **B2C:** 개인 투자자 — 기관 수준 구조를 단순한 UI로 제공.
|
||||
- **B2B:** 애널리스트, PM, Corp Dev — 공시 → 인사이트 → 밸류를 한 흐름으로 처리.
|
||||
|
||||
### 1.3 단위 경제
|
||||
- **정성:** Gemini **1회 호출** (Item 7 또는 1A당) → 토큰 비용 최소화.
|
||||
- **정량:** yfinance / yahooquery **무료** → 숫자 정확도 확보, API 비용 없음.
|
||||
|
||||
---
|
||||
|
||||
## 2. 아키텍처 개요
|
||||
|
||||
### 2.1 하이브리드 분리 원칙
|
||||
| 파이프라인 | 담당 | 데이터 소스 | 비고 |
|
||||
|-----------|------|-------------|------|
|
||||
| **정성 (Qualitative)** | LLM (Gemini 2.0 Flash) | SEC EDGAR 10-K (Item 1A, 3, 7, 9A) | 토큰 절감을 위해 Item 7·1A만 전송 |
|
||||
| **정량 (Quantitative)** | Pandas + yfinance/yahooquery | Yahoo Finance API | TTM/분기 폴백, 다중 속성 폴백 |
|
||||
|
||||
### 2.2 아키텍처 다이어그램 (요약)
|
||||
```
|
||||
User → Streamlit UI (3 Tabs)
|
||||
├─ Tab1: 10-K & MD&A Insights
|
||||
│ ├─ Qual: SEC 10-K → Parser → Item 1A/7 → Gemini → 전략·리스크 리포트
|
||||
│ └─ Quant: yfinance → DuPont, Altman Z, Sankey, Radar, F-Score, YoY, Red Flags
|
||||
├─ Tab2: DCF Valuation
|
||||
│ └─ Quant: yfinance → FCF/Debt/Cash/Shares → 10Y 2-Stage DCF, Bull/Base/Bear
|
||||
└─ Tab3: Industry Analysis
|
||||
├─ Quant: yfinance → Peer P/E, EV/EBITDA, P/B (조건부 포맷팅)
|
||||
└─ Qual: Gemini → Industry Outlook (거시 트렌드)
|
||||
```
|
||||
|
||||
### 2.3 주요 아키텍처 결정 (ADL)
|
||||
- **ADL-001:** 429 대응 — Item 7·1A만 추출, Item 8 숫자는 yfinance로 대체 → 토큰 80%+ 절감.
|
||||
- **ADL-002:** 10년 2단계 DCF — 5년 DCF의 터미널 가치 왜곡 완화; Y1–5 성장, Y6–10 Fade 후 TV.
|
||||
- **ADL-003:** 다중 폴백 — yahooquery 1순위 → yfinance (fast_info → info → balance_sheet) → TTM/분기 합산.
|
||||
|
||||
---
|
||||
|
||||
## 3. 기술 스택 및 인프라
|
||||
|
||||
### 3.1 런타임
|
||||
- **Python:** 3.9+
|
||||
- **프레임워크:** Streamlit ≥ 1.28.0
|
||||
|
||||
### 3.2 외부 API
|
||||
| API | 라이브러리 | 용도 | 인증 |
|
||||
|-----|-----------|------|------|
|
||||
| Google Gemini | google-generativeai ≥ 0.8.0 | MD&A 인사이트, 리스크, 산업 전망 | GOOGLE_API_KEY |
|
||||
| SEC EDGAR | sec-edgar-downloader ≥ 5.0.0 | 최신 10-K HTML 다운로드 | SEC_EDGAR_EMAIL (User-Agent) |
|
||||
| Yahoo Finance | yfinance ≥ 0.2.40, yahooquery ≥ 2.2.0 | 검색, 재무제표, 멀티플, 컨센서스 | 없음 |
|
||||
|
||||
### 3.3 데이터 처리
|
||||
- **Pandas** ≥ 2.0.0 — 재무 시계열, DCF, comps 테이블.
|
||||
- **BeautifulSoup4** ≥ 4.12.0, **lxml** ≥ 4.9.0 — HTML 파싱·클렌징.
|
||||
- **Plotly** ≥ 5.18.0 — Sankey, Radar, 5년 트렌드 라인 차트.
|
||||
|
||||
---
|
||||
|
||||
## 4. 데이터 흐름 상세
|
||||
|
||||
### 4.1 정성 데이터 흐름 (Qualitative Flow)
|
||||
1. **다운로드:** `sec-edgar-downloader`로 티커당 최신 10-K HTML (임시 디렉터리).
|
||||
2. **슬라이싱:** `_slice_html_items_1a_to_9a()` — 원문에서 Item 1A ~ Item 9A 구간만 문자열로 추출 (대용량 파일 회피).
|
||||
3. **파싱·클렌징:** `extract_text_from_html()` → BeautifulSoup(lxml)으로 table/img/script 제거 → `find_item_section_generic()`로 Item 1A, 3, 7, 8, 9A 추출 → `clean_text_for_llm()` (태그·공백 정리).
|
||||
4. **캐싱:** `data/{TICKER}_latest.json`에 item1a, item3, item7, item8, item9a 저장. 재실행 시 다운로드 생략.
|
||||
5. **Chunking:** `smart_chunk(section, max_chars=10000)` — 긴 Item 7은 앞·뒤 비율로 압축.
|
||||
6. **LLM:** Gemini `generate_content` (또는 `stream=True`) — 전략(Item 7), 리스크(Item 1A), 포렌식(Item 3·9A). 429 시 `_generate_with_retry()`로 60초 대기 후 재시도.
|
||||
|
||||
### 4.2 정량 데이터 흐름 (Quantitative Flow)
|
||||
1. **검색:** 사이드바 — yahooquery `search(query)` → EQUITY/ETF만 필터, INDEX/MUTUALFUND 제외 → `[Exchange] Symbol - Name` 선택.
|
||||
2. **티커 정규화:** `get_global_ticker(ticker, market)` — US 그대로, 한국 .KS/.KQ, 일본 .T, UK .L.
|
||||
3. **재무 데이터:** `_get_annual_financials_balance_cashflow(ticker)` — yahooquery 1순위, 실패 시 yfinance annual → 비어 있으면 quarterly(TTM) 폴백.
|
||||
4. **DCF 입력:** `get_dcf_inputs()` — FCF=OCF−CapEx, Shares (fast_info → info → balance_sheet), Debt/Cash 동일 다단계 폴백.
|
||||
5. **시각화·지표:** DuPont, Altman Z, Piotroski F-Score, Sankey, Radar, YoY, Red Flags — 모두 `_safe_float()` 및 N/A 처리.
|
||||
|
||||
---
|
||||
|
||||
## 5. 코드 구조
|
||||
|
||||
### 5.1 파일 레이아웃
|
||||
```
|
||||
FQDC Project/
|
||||
├── app.py # 메인 앱 (~3039줄): UI, SEC, Gemini, DCF, Comps, 차트
|
||||
├── find_toc.py # 10-K HTML 목차(TOC) 탐색 유틸 — CLI 또는 URL 인자
|
||||
├── push_to_github.sh # 원격 푸시 스크립트
|
||||
├── requirements.txt # 의존성
|
||||
├── .env.example # GOOGLE_API_KEY, SEC_EDGAR_EMAIL 템플릿
|
||||
├── .gitignore # venv, .env, .app_prefs.json, data/
|
||||
├── data/ # 런타임: 10-K JSON 캐시 (티커별)
|
||||
├── .app_prefs.json # 런타임: API 키·이메일 로컬 저장 (선택)
|
||||
├── .agent/ # 에이전트/문서
|
||||
│ ├── prd.md # 제품 요구사항
|
||||
│ ├── architecture.mermaid
|
||||
│ ├── flows.md # 정성/정량 흐름 설명
|
||||
│ ├── directory_map.md
|
||||
│ ├── adl.yaml # 아키텍처 결정
|
||||
│ ├── infra.yaml # 인프라·의존성
|
||||
│ ├── manifest.json # 메타데이터
|
||||
│ └── rules.md # 개발 가드레일
|
||||
├── TECHNICAL_NOTES.md # 429 대응·토큰 최적화 기술 노트
|
||||
├── README.md
|
||||
└── AGENT.md # 에이전트 진입점
|
||||
```
|
||||
|
||||
### 5.2 app.py 함수 그룹 (요약)
|
||||
| 구간 | 역할 | 대표 함수 |
|
||||
|------|------|-----------|
|
||||
| 설정·유틸 | 프리퍼런스, 경로 | `_load_prefs`, `_save_prefs`, `_PREFS_PATH`, `_DATA_DIR` |
|
||||
| 티커·시장 | 검색·접미사 | `get_global_ticker`, `infer_market_from_ticker`, `SECTORS` |
|
||||
| SEC·10-K | 다운로드·파싱·캐시 | `_slice_html_items_1a_to_9a`, `extract_text_from_html`, `find_item_section_generic`, `download_and_extract_all_items`, `get_10k_sections`, `_load_10k_from_cache`, `_save_10k_to_cache` |
|
||||
| Gemini | 모델·재시도·스트리밍 | `get_gemini_model`, `_generate_with_retry`, `_generate_stream`, `get_gemini_item7_strategy`, `get_gemini_item7_strategy_stream`, `get_gemini_item1a_risks`, `get_gemini_item1a_risks_stream`, `_gemini_forensic_audit`, `get_sec_financials_llm`, `get_industry_outlook` |
|
||||
| 정량·재무 | yfinance/yyahooquery | `_get_annual_financials_balance_cashflow`, `_get_row_series`, `get_dcf_inputs`, `get_dcf_smart_defaults`, `get_analyst_consensus`, `get_sector_industry`, `get_5yr_financial_trend` |
|
||||
| DCF | 10년 2단계 | `dcf_10y_2stage`, `excel_style_dcf`, `_damodaran_wacc_for_sector` |
|
||||
| 지표·차트 | DuPont, Altman, F-Score, Sankey, Radar | `get_dupont_altman_redflags_yoy`, `get_piotroski_fscore`, `get_income_statement_sankey_data`, `_build_sankey_figure`, `_build_radar_figure`, `get_sector_specific_metrics` |
|
||||
| Comps | 동종사 멀티플 | `get_comps_data` |
|
||||
| UI | 탭·사이드바 | 2305줄~ `st.tabs`, Tab1/2/3 블록, 사이드바 검색·선택 |
|
||||
|
||||
### 5.3 캐싱 전략
|
||||
- **Streamlit:** `@st.cache_data(ttl=300)` (5분) — `get_dcf_inputs`, `get_analyst_consensus`, `get_dupont_altman_redflags_yoy`, `get_comps_data`, `get_5yr_financial_trend`, `get_sector_industry`, `get_radar_metrics_normalized`, `get_piotroski_fscore`, `get_sector_specific_metrics`, `_get_annual_financials_balance_cashflow` 등.
|
||||
- **Item8 LLM:** `@st.cache_data(ttl=3600)` — `get_sec_financials_llm` (1시간).
|
||||
- **10-K 본문:** `data/{ticker}_latest.json` — 캐시 존재 시 `get_10k_sections()`에서 다운로드·파싱 생략.
|
||||
- **상태:** `st.session_state` — ticker, market, google_api_key, sec_email, mda_strategy_result, mda_risk_result, company_search_options 등.
|
||||
|
||||
---
|
||||
|
||||
## 6. 탭별 기능 상세
|
||||
|
||||
### 6.1 Tab 1 — 10-K & MD&A Insights
|
||||
- **상단:** Sector/Industry 뱃지 (get_sector_industry).
|
||||
- **Financial Health:**
|
||||
- Sankey: 손익 흐름 (Item 8 LLM 추출 또는 yfinance).
|
||||
- Radar: ROE, Current Ratio, Asset Turnover, Equity Mult., Revenue YoY (정규화).
|
||||
- Piotroski F-Score (9점), Altman Z-Score, Red Flags (Current Ratio < 1.0, Interest Coverage < 1.5).
|
||||
- Sector-specific: Tech(Rule of 40, FCF margin, R&D%), Retail(재고회전율, 영업이익률), Financials(ROE, ROA).
|
||||
- YoY 비율 변화, 분기별 모멘텀 테이블 (녹색/빨간색 조건부 스타일).
|
||||
- **Deep-Dive (AI):**
|
||||
- US 한정: "Analyze Management Strategy (MD&A)" → Item 7 스트리밍; "Analyze Risk Factors (Item 1A)" → Item 1A 스트리밍 + Item 3·9A 포렌식.
|
||||
- 한국/일본/UK: 현재 경고 메시지 (DART/EDINET/LSE Phase 2 예정).
|
||||
- 결과는 session_state에 저장 후 expander로 이전 분석 표시.
|
||||
|
||||
### 6.2 Tab 2 — 3-Scenario DCF Valuation
|
||||
- **5년 트렌드:** Revenue, Net Income, Operating Margin %, FCF (YoY), Plotly 라인 차트.
|
||||
- **DCF 입력:** Base FCF (OCF−CapEx 자동 또는 수동), Shares/Debt/Cash (자동 다단계 폴백, 실패 시만 수동).
|
||||
- **슬라이더:** WACC, Terminal Growth (기본 2.5%), Projected FCF Growth (Y1–5). 기본값: `get_dcf_smart_defaults()` — Beta(CAPM), revenueGrowth/earningsGrowth 기반.
|
||||
- **Reference 패널:** 애널리스트 컨센서스 (목표가, 추천, 성장률) + Damodaran (섹터 WACC, ERP 4.6%, Rf 4.2%, 링크).
|
||||
- **출력:** Bull/Base/Bear (FCF 성장률 ±2%) 내재가치, 현재가 대비, 3시나리오 테이블.
|
||||
|
||||
### 6.3 Tab 3 — Industry Analysis & Comps
|
||||
- **산업 선택:** SECTORS (Semiconductors & Hardware, Software & Cloud, Consumer Retail, Financial Services, Healthcare) → 해당 산업 Top 5 티커.
|
||||
- **Comps 테이블:** Forward P/E, EV/EBITDA, P/B — 최소값 녹색, 최대값 빨간색 조건부 포맷; N/A 처리.
|
||||
- **AI Industry Outlook:** Gemini로 12–18개월 거시 트렌드, 성장 동력, 리스크 요약.
|
||||
|
||||
---
|
||||
|
||||
## 7. 핵심 알고리즘
|
||||
|
||||
### 7.1 10년 2단계 DCF (`dcf_10y_2stage`)
|
||||
- **Stage 1 (Y1–5):** FCF가 매년 `fcf_growth`로 성장.
|
||||
- **Stage 2 (Y6–10):** 성장률이 `fcf_growth`에서 `term_growth`까지 선형 Fade (`fade = (t-6)/4`).
|
||||
- **Terminal Value:** Y10 FCF × (1 + term_growth) / (wacc - term_growth), Y10 시점으로 할인.
|
||||
- **Equity:** EV − Total Debt + Cash; 주당가치 = Equity / Shares.
|
||||
|
||||
### 7.2 DuPont 3단계
|
||||
- ROE = NPM × Asset Turnover × Equity Multiplier (연도별).
|
||||
- NPM = Net Income / Revenue, AT = Revenue / Total Assets, EM = Total Assets / Equity.
|
||||
|
||||
### 7.3 Altman Z-Score
|
||||
- Z = 1.2×(WC/TA) + 1.4×(RE/TA) + 3.3×(EBIT/TA) + 0.6×(MC/TL) + 1.0×(Sales/TA). Safe > 2.99, Distress < 1.81.
|
||||
|
||||
### 7.4 Smart Defaults (DCF)
|
||||
- WACC: Beta(기본 1.0), Rf 4%, MRP 5% → CAPM 근사.
|
||||
- Terminal: 2.5% (Damodaran 스타일).
|
||||
- FCF Growth: revenueGrowth 또는 earningsGrowth (예: 0.15 → 15%); 없으면 8%.
|
||||
|
||||
---
|
||||
|
||||
## 8. 에러 처리 및 개발 규칙 (rules.md)
|
||||
|
||||
- **예외·폴백:** 모든 금융 API 호출 try/except; 실패 시 빈 DataFrame 또는 0.0/None; 수치 파싱은 `_safe_float()` 사용.
|
||||
- **토큰:** HTML 클렌징 후 `smart_chunk()`; 429 시 `_generate_with_retry()` (60초 대기).
|
||||
- **캐싱:** 재무/분석 `@st.cache_data(ttl=300)`; 10-K는 `data/` JSON 영구 캐시.
|
||||
- **표시:** NaN/None은 `_na()`로 "N/A" 통일.
|
||||
|
||||
---
|
||||
|
||||
## 9. 보안 및 환경
|
||||
|
||||
- **비밀:** `.env`에 GOOGLE_API_KEY, SEC_EDGAR_EMAIL (`.gitignore`).
|
||||
- **로컬 저장:** "Remember API key & email" 선택 시 `.app_prefs.json` (역시 `.gitignore`).
|
||||
- **SEC 정책:** User-Agent에 연락용 이메일 필수.
|
||||
|
||||
---
|
||||
|
||||
## 10. 제한 사항 및 로드맵
|
||||
|
||||
- **한국/일본/UK:** 10-K 대신 DART/EDINET/LSE 연동은 "Phase 2" 또는 "under development" 상태; US만 전체 정성 플로우 지원.
|
||||
- **실행 시간:** Tab 1 첫 10-K 로드 20–60초, Gemini 스트리밍 5–10초; Tab 2·3는 yfinance만으로 수 초.
|
||||
- **로드맵:** MVP → 상용화(B2C/B2B SaaS) 목표.
|
||||
|
||||
---
|
||||
|
||||
## 11. 개선 제안
|
||||
|
||||
1. **모듈 분리:** app.py를 `sec_edgar.py`, `gemini_analysis.py`, `dcf.py`, `comps.py`, `charts.py`, `ui_tabs.py` 등으로 나누면 유지보수·테스트 용이.
|
||||
2. **단위 테스트:** DCF 공식, DuPont/Altman 계산, `_safe_float`/폴백 로직에 대한 pytest 추가.
|
||||
3. **AGENT.md 경로:** 문서가 `agent/`가 아닌 `.agent/`에 있으므로 AGENT.md 내 링크를 `./.agent/`로 통일하거나 디렉터리명 정리.
|
||||
4. **TECHNICAL_NOTES.md:** `prefilter_after_item7` 등 현재 코드와 다른 함수명이 문서에 있을 수 있음 — 코드 기준으로 문서 동기화 권장.
|
||||
|
||||
---
|
||||
|
||||
이 문서는 프로젝트 루트의 `PROJECT_ANALYSIS.md`로 저장되었으며, 에이전트·신규 개발자가 전체 구조와 규칙을 빠르게 파악하는 데 활용할 수 있습니다.
|
||||
@@ -1,29 +0,0 @@
|
||||
# Technical Notes: 10-K Financial Analyzer
|
||||
|
||||
Reference document for developers and reviewers. This describes a key architectural decision made during development.
|
||||
|
||||
---
|
||||
|
||||
## Technical Challenge: Handling Large-Scale Financial Filings
|
||||
|
||||
During the initial development, I encountered a **429 Resource Exhausted** error due to the massive size of 10-K filings exceeding the LLM's token quota and rate limits.
|
||||
|
||||
### Consultation & Architectural Pivot
|
||||
|
||||
After consulting with a senior software engineer, I re-architected the application to optimize token usage. Instead of processing the entire document, I implemented a **"Selective Section Extraction"** strategy.
|
||||
|
||||
### Implemented Solution
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| **Targeted Parsing** | Developed a regex-based parser to isolate only critical sections: **Item 7 (MD&A)** and **Item 8 (Financial Statements)**. |
|
||||
| **Token Optimization** | Integrated a **"Chunking & Filtering"** logic to remove boilerplate legal text, sending only high-signal data to the Gemini API. |
|
||||
| **Efficiency** | This reduced token consumption by **over 80%**, ensuring stable performance within free-tier limits while maintaining analytical depth. |
|
||||
|
||||
### Code References
|
||||
|
||||
- **Section extraction**: `find_item_section()`, `ITEM7_PATTERNS`, `ITEM8_PATTERNS` in `app.py`
|
||||
- **Pre-filtering**: `prefilter_after_item7()` — drops PART I, ITEM 1–6; only content from Item 7 onward is used
|
||||
- **Smart chunking**: `smart_chunk()` — when a section exceeds a character limit, keeps head + tail to preserve quantitative data while cutting tokens
|
||||
|
||||
These changes allow the app to stay within API rate limits without sacrificing the quality of the CFA-style analysis.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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.
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>
|
||||
);
|
||||
}
|
||||
@@ -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 "Run Analysis" 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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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": [],
|
||||
}
|
||||
@@ -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": {},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
Reference in New Issue
Block a user