Initial commit with full project improvements

Security: config-based CORS, default secret warnings, sort_by validation
Error handling: debug logging in cache silent failures
Architecture: shared resolve_time_parameters, deduplicated logger init, unified route structure
Database: conditional SQLite/PostgreSQL engine, in-memory test DB, dialect-aware date formatting, optimized stats query
Docker: .dockerignore, pinned yfinance_plus commit
Dependencies: removed duplicates, added version upper bounds, removed unused axios
Frontend: custom _document/_error pages, adminApi client, Layout standardization, ESLint version update

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 7 months ago
commit 355aeb19d3

@ -0,0 +1,16 @@
.git
.gitignore
.env
.claude
__pycache__
*.pyc
.pytest_cache
tests/
docs/
examples/
frontend/
portainer/
*.db
*.md
!requirements*.txt
data/

127
.gitignore vendored

@ -0,0 +1,127 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
/lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Virtual environments
.venv/
venv/
env/
ENV/
# Environment variables
.env
.env.local
.env.production
.env.test
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Database files
*.db
*.sqlite
*.sqlite3
data/
# Log files
*.log
logs/
# Docker
# .dockerignore is tracked in git
# Node.js (Frontend)
frontend/node_modules/
frontend/.next/
frontend/out/
frontend/build/
frontend/.cache/
frontend/.parcel-cache/
# npm
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Temporary files
*.tmp
*.temp
*~
.#*
# Test artifacts
test-results/
test-output/
coverage/
# Backup files
*backup*
*.bak
# Debug files
debug_*.py
*_debug.py
test_debug*.py
# Stress test files
stress_test*.py
*stress*.py
# Mock/temporary files
mock_*.py
temp_*.py
# Embedded repos
yfinance_plus/

@ -0,0 +1,463 @@
# Stock Oracle API Documentation 🔮
**Comprehensive Investment Data Analysis API**
Stock Oracle provides comprehensive financial, price, news, and social media data analysis through a unified REST API. Built for investors, analysts, and developers who need reliable access to SEC filings, market data, and sentiment analysis.
---
## 🚀 Quick Start
### Base URL
```
http://localhost:18001/api/v1
```
### Authentication
Currently no authentication required. API key support coming soon.
### Rate Limits
- 100 requests per minute per IP
- 10,000 requests per day per IP
---
## 📊 Core Data Sources
- **SEC EDGAR**: Official company filings (10-K, 10-Q, N-PORT)
- **Yahoo Finance**: Real-time price data and news
- **NewsAPI**: Professional news aggregation
- **Reddit API**: Social sentiment analysis
---
## 🎯 API Endpoints
### Health & System
#### `GET /health`
Basic health check with system status.
**Response:**
```json
{
"status": "healthy",
"version": "1.0.0",
"database": "healthy",
"cache": "healthy",
"sec_data_available": true,
"timestamp": "2025-08-10T17:59:27.790041"
}
```
#### `GET /health/detailed`
Detailed system health with component status.
---
### Financial Data
#### `POST /financial/data`
Get comprehensive financial data for a ticker.
**Request Body:**
```json
{
"ticker": "AAPL",
"period": "1y",
"period_type": "quarterly",
"include_metrics": true,
"force_refresh": false
}
```
**Alternative Time Specifications:**
```json
// Date Range
{
"ticker": "AAPL",
"start_date": "2023-01-01",
"end_date": "2023-12-31"
}
// Specific Quarters
{
"ticker": "AAPL",
"quarters": ["2024Q1", "2024Q2", "2024Q3"]
}
```
**Response:**
```json
{
"company": {
"ticker": "AAPL",
"name": "Apple Inc.",
"cik": "320193",
"sector": "Technology",
"industry": "Consumer Electronics"
},
"financial_data": [
{
"period_date": "2024-06-30",
"period_type": "quarterly",
"revenue": 85777000000,
"gross_profit": 35398000000,
"operating_income": 24261000000,
"net_income": 21448000000,
"eps": 1.40,
"pe_ratio": 28.5,
"roe": 0.63,
"debt_to_equity": 1.97,
"market_cap": 3200000000000
}
],
"metadata": {
"data_points": 8,
"period_type": "quarterly",
"last_updated": "2025-08-10T12:00:00"
}
}
```
#### `GET /financial/data/{ticker}`
Simplified financial data endpoint with query parameters.
**Parameters:**
- `period`: Time period (1d, 7d, 30d, 1m, 3m, 6m, 1y, 2y, 5y, 10y)
- `period_type`: quarterly, annual, all
- `include_metrics`: true/false
- `force_refresh`: true/false
#### `POST /financial/data/bulk`
Get financial data for multiple tickers in a single request.
**Request:**
```json
{
"tickers": ["AAPL", "MSFT", "GOOGL"],
"period": "1y",
"include_metrics": true
}
```
---
### Price Data
#### `POST /price/data`
Get historical price data (OHLCV) for a ticker.
**Request:**
```json
{
"ticker": "AAPL",
"period": "30d",
"interval": "1d",
"force_refresh": false
}
```
**Supported Intervals:**
- `1m`, `2m`, `5m`, `15m`, `30m`, `60m`, `90m` (minutes)
- `1h` (hour)
- `1d`, `5d` (days)
- `1wk` (week)
- `1mo`, `3mo` (months)
**Response:**
```json
{
"ticker": "AAPL",
"price_data": [
{
"date": "2024-08-10",
"open": 220.05,
"high": 225.30,
"low": 218.75,
"close": 224.72,
"volume": 45234567,
"adj_close": 224.72
}
],
"interval": "1d"
}
```
#### `POST /price/data/bulk`
Bulk price data for multiple tickers.
---
### Stock Market Data 🆕
#### `GET /stocks/most-active`
Most actively traded stocks from Yahoo Finance.
**Parameters:**
- `limit`: Number of stocks to return (1-500). If omitted, returns all available (~170)
- `force_refresh`: true/false (default: false). When true, bypasses cache and fetches fresh data
**Caching:**
- Server-side cache TTL: 1 hour
- Cache key: `stocks:most-active:limit=<N|all>`
- Response headers:
- `X-Cache`: `HIT` | `MISS` | `BYPASS`
- `Cache-Control`: `public, max-age=3600`
- `ETag`: Strong hash of the response
- `X-Data-Source`: `redis-cache` | `scraper`
**Examples:**
```
# Default (cached up to 1h)
GET /stocks/most-active
# Limit results (cached per limit)
GET /stocks/most-active?limit=100
# Force fresh fetch (bypass cache)
GET /stocks/most-active?force_refresh=true
# Limit + fresh
GET /stocks/most-active?limit=50&force_refresh=true
```
---
### News & Social Media 🆕
#### `GET /news/{ticker}`
Complete news and social media data for a ticker.
**Parameters:**
- `days_back`: Days to look back (1-30, default: 7)
- `max_articles`: Max news articles (5-100, default: 20)
- `max_social_posts`: Max social posts (0-100, default: 15)
- `include_social`: Include social media (true/false, default: true)
**Example:**
```
GET /news/AAPL?days_back=7&max_articles=20&include_social=true
```
**Response:**
```json
{
"ticker": "AAPL",
"retrieved_at": "2025-08-10T17:40:04.781906",
"news": {
"total_articles": 12,
"sources": {
"yahoo_finance": 6,
"newsapi": 6
},
"articles": [
{
"title": "Apple Reports Strong Q3 Results",
"summary": "Apple exceeded expectations with record iPhone sales...",
"url": "https://finance.yahoo.com/news/apple-q3-2024",
"source": "Yahoo Finance",
"published_at": "2025-08-10T14:30:00",
"author": "John Smith",
"tags": ["earnings", "iphone", "revenue"]
}
]
},
"social_media": {
"total_posts": 8,
"platforms": {"reddit": 8},
"posts": [
{
"title": "$AAPL breakout incoming? Technical analysis",
"content": "Looking at the charts, AAPL seems to be forming...",
"url": "https://reddit.com/r/stocks/comments/xyz",
"platform": "Reddit",
"author": "trader123",
"published_at": "2025-08-10T16:20:00",
"score": 245,
"comments_count": 67,
"subreddit": "stocks"
}
]
},
"summary": {
"total_items": 20,
"time_range_days": 7,
"newest_item": "2025-08-10T16:20:00",
"oldest_item": "2025-08-03T09:15:00"
}
}
```
#### `GET /news/{ticker}/news-only`
News articles only (faster response, no social media).
#### `GET /news/{ticker}/social-only`
Social media posts only.
---
### ETF Holdings
Temporarily unavailable. The ETF API is being redesigned. Previous endpoints under `/etf/*` have been removed and will return 404. See docs/ETF_API.md for historical reference only.
---
### Database & Metadata
#### `GET /database/stats`
Database statistics and data coverage information.
#### `GET /metadata/catalog`
Complete data field catalog with descriptions and types.
---
### Admin & Monitoring
#### `GET /admin/errors/logs`
Error log retrieval (admin access).
**Parameters:**
- `limit`: Number of logs (default: 100)
- `offset`: Pagination offset (default: 0)
- `min_level`: Minimum log level (ERROR, WARNING, INFO)
#### `GET /admin/errors/stats`
Error statistics and trends.
#### `POST /admin/migrate`
Database migration from another Stock Oracle instance.
---
## 🔧 Python Client Usage
### Installation
```bash
# Download the client from the repository
wget https://raw.githubusercontent.com/your-repo/stock-oracle/main/stock_oracle_client.py
```
### Basic Usage
```python
from stock_oracle_client import StockOracleClient
# Initialize client
client = StockOracleClient("http://localhost:18001")
# Check API health
health = client.get_health()
print("API Status:", health["status"])
# Get financial data
data = client.get_financial_data("AAPL", period="1y")
print(f"Found {len(data['financial_data'])} quarters of data")
# Get news and social media data
news = client.get_news_social_data("AAPL", days_back=7, max_articles=20)
print(f"Found {news['summary']['total_items']} news/social items")
# Get ETF holdings
etf = client.get_etf_holdings("QQQ", include_holdings=False)
print(f"QQQ has {etf['data']['holdings_count']} holdings")
# Bulk operations
bulk_data = client.get_bulk_financial_data(
tickers=["AAPL", "MSFT", "GOOGL"],
period="2y"
)
```
### Error Handling
```python
from stock_oracle_client import StockOracleAPIError, ETFDataNotAvailableError
try:
data = client.get_financial_data("INVALID_TICKER")
except StockOracleAPIError as e:
print(f"API Error: {e}")
print(f"Status Code: {e.status_code}")
try:
etf = client.get_etf_holdings("QQQ", as_of_date="1990-01-01")
except ETFDataNotAvailableError as e:
print(f"ETF data not available: {e}")
if e.availability_info:
print(f"Available from: {e.availability_info['available_date_range']['start']}")
```
---
## 📈 Investment Analysis
### Stock Oracle Analyzer
The included analyzer provides comprehensive investment analysis combining all data sources:
```python
from stock_oracle_analyzer import StockOracleAnalyzer
# Initialize analyzer
analyzer = StockOracleAnalyzer("http://localhost:18001")
# Analyze single company
data = analyzer.get_company_data("AAPL", period="2y")
analysis = data['analysis_summary']
print(f"Investment Score: {analysis['investment_score']}/100")
print(f"Financial Grade: {analysis['financial_health']['grade']}")
print(f"Price Trend: {analysis['price_trends']['trend']}")
print(f"Sentiment: {analysis['sentiment_analysis']['sentiment_label']}")
# Compare multiple companies
tickers = ["AAPL", "MSFT", "GOOGL", "TSLA", "NVDA"]
results = analyzer.analyze_multiple_companies(tickers, period="1y")
# Generate comparison report
summary_df = analyzer.create_summary_report(results)
print(summary_df[['Ticker', 'Investment Score', 'Financial Grade', 'Sentiment']])
```
---
## ⚠️ Important Notes
### Data Availability
- **Period parameters** automatically use **yesterday** as end date to ensure data availability
- **SEC filings** may have delays - latest data is typically 1-3 months behind
- **ETF holdings** are updated quarterly via N-PORT filings
- **News data** is real-time but may have API rate limits
### Performance Tips
- Use **bulk endpoints** for multiple tickers to reduce latency
- Enable **caching** by avoiding `force_refresh=true` unless necessary
- Use **news-only** endpoints for faster sentiment analysis
- Implement **client-side caching** for frequently accessed data
### Error Codes
- `400`: Bad Request (invalid parameters)
- `404`: Data not found (ticker not found, no filings available)
- `429`: Rate limit exceeded
- `500`: Internal server error
- `503`: Service temporarily unavailable
---
## 🔗 Links
- **Interactive API Docs**: [/api/v1/docs](/api/v1/docs) (Swagger UI)
- **Alternative Docs**: [/api/v1/redoc](/api/v1/redoc) (ReDoc)
- **Health Check**: [/api/v1/health](/api/v1/health)
- **GitHub Repository**: [View on GitHub](https://github.com/your-repo/stock-oracle)
---
## 📞 Support
- **Issues**: Report bugs and feature requests on GitHub
- **Documentation**: This page is auto-generated from the latest API specification
- **Updates**: Check the changelog for latest features and improvements
---
*Stock Oracle - Empowering investment decisions with comprehensive data analysis* 🔮📈

@ -0,0 +1,57 @@
# Changelog
All notable changes to Stock Oracle API will be documented in this file.
## [2.1.0] - 2025-08-10
### Added
- **ETF Holdings API v2**: Complete rewrite with enhanced features
- `availability` field in all error responses showing available date ranges
- ETF launch date validation to prevent invalid historical requests
- Automatic detection when ETF didn't exist on requested date
- Fast performance optimization (<0.1s response time, down from 35s)
- Enhanced error messages with actionable information
### Improved
- **Performance**: ETF date validation now uses cached launch dates for instant response
- **User Experience**: Clear error messages when ETF data is unavailable
- **Documentation**: Comprehensive API documentation with examples
### Fixed
- Historical date requests now correctly validate against ETF launch dates
- QQQM pre-launch date requests now return proper error instead of wrong data
- Response model now includes all fields (fixed Pydantic model filtering issue)
## [2.0.0] - 2025-07-01
### Added
- **ETF Holdings API**: New endpoint for ETF portfolio data
- Support for 30+ major ETFs with pre-configured mappings
- Automatic CIK to ticker conversion
- Historical NPORT data from 2019 onwards
- Support for both ticker symbols and CIK numbers
### Changed
- Simplified ETF API to single `/holdings/{ticker}` endpoint
- Removed redundant ETF endpoints
## [1.5.0] - 2024-12-01
### Added
- **Price Data API**: OHLCV data integration with yfinance
- **Bulk Data Support**: Batch requests for multiple tickers
- **Period Strings**: Convenient time period specification (1y, 6m, 3m, etc.)
### Improved
- Database caching strategy
- Error handling and logging
- API documentation
## [1.0.0] - 2024-10-01
### Initial Release
- **Financial Data API**: SEC filing data extraction
- **Metrics Calculation**: P/E, P/B, ROE, margins, etc.
- **Database Caching**: SQLite/PostgreSQL support
- **Docker Deployment**: Complete containerization
- **API Documentation**: Interactive Swagger/OpenAPI docs

@ -0,0 +1,27 @@
# Simple Dockerfile for SEC Investment API with SQLite
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install git for yfinance-plus installation
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
# Copy requirements and install dependencies
COPY requirements-api.txt .
RUN pip install --no-cache-dir -r requirements-api.txt
# Install updated yfinance_plus from Gitea repository
RUN pip install git+https://gitea.yirugi.synology.me/yirugi/yfinance_plus.git@d18976d4aa58c9df58c55f47a77b568d8ce8581e
# Copy application code
COPY . .
# Create data directory for SQLite
RUN mkdir -p /app/data
# Expose port
EXPOSE 18000
# Run the application
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "18000", "--reload"]

@ -0,0 +1,894 @@
# Stock Oracle 🔮
**Investment Data Analysis API using SEC filings**
Stock Oracle is a comprehensive investment analysis API that leverages SEC EDGAR filing data to provide detailed financial metrics and insights for informed investment decision-making.
## 🎯 Features
### Core Investment Metrics
- **Valuation Ratios**: P/E, P/B, P/S, EV/EBITDA
- **Profitability**: ROE, ROA, Gross/Operating/Net Margins
- **Growth Metrics**: Revenue Growth, Earnings Growth YoY
- **Financial Health**: Debt-to-Equity, Market Cap
- **Sector Analysis**: Industry and sector categorization
### Market Data Intelligence (NEW! 🆕)
- **Most Active Stocks**: Real-time ~170 most actively traded stocks (sub-5s response)
- **52-Week Gainers**: 1,350+ top gaining stocks with intelligent rate limiting (5-90s)
- **FRED Economic Data**: Federal Reserve economic indicators with smart caching (1000/day limit)
- **Advanced Web Scraping**: curl_cffi + Chrome impersonation bypasses rate limits
- **Smart Pagination**: Configurable page limits (1-10 pages) for performance tuning
- **Multi-Source News**: Yahoo Finance + NewsAPI integration
- **Social Media Analysis**: Reddit sentiment and discussions
- **Real-Time Updates**: Fresh content aggregation with performance monitoring
- **Sentiment Analysis**: Automated content sentiment scoring
- **Historical Context**: Customizable time periods (1-30 days)
### API Capabilities
- **RESTful API**: FastAPI-based with OpenAPI documentation
- **Database Caching**: Intelligent caching to avoid duplicate SEC parsing
- **Date Range Queries**: Flexible time period analysis
- **Data Validation**: Comprehensive request/response validation
- **Error Handling**: Detailed error categorization and reporting
- **Migration Support**: Database transfer capabilities
### Technical Stack
- **Backend**: FastAPI, SQLAlchemy (async), Pydantic
- **Frontend**: Next.js 15, React 18, TypeScript, TailwindCSS
- **Database**: SQLite (development) / PostgreSQL (production)
- **Caching**: Redis for performance optimization
- **Data Sources**:
- SEC EDGAR via edgartools (ETF holdings, financial data)
- Yahoo Finance via yfinance_plus (news & prices)
- Yahoo Finance via intelligent curl_cffi scraping (market data)
- Most Active Stocks (~170 stocks)
- 52-Week Gainers (~1,350 stocks with rate limiting)
- NewsAPI (news articles)
- Reddit API (social media sentiment)
- **Deployment**: Docker with docker-compose
- **Testing**: Comprehensive test suite with pytest
## 🚀 Quick Start
### Using Docker (Recommended)
```bash
# Clone and navigate
git clone <repo-url>
cd stock-oracle
# Start all services
docker-compose up -d
# Services will be available at:
# - API: http://localhost:18001
# - API Documentation: http://localhost:18001/docs
# - Frontend: http://localhost:18002
# - Error Log Viewer: http://localhost:18002/errors
# - PostgreSQL: localhost:15433
# - Redis: localhost:16380
```
### Local Development
```bash
# Install dependencies
pip install -r requirements-api.txt
# Set up environment
cp .env.example .env
# Edit .env with your settings
# Run the API
python -m uvicorn app.main:app --host 0.0.0.0 --port 18000 --reload
```
## ⚡ Quick Examples
### Get Market Overview
```bash
# Trending stocks - best of both worlds (recommended, default: 500 total stocks)
curl "http://localhost:18001/api/v1/stocks/trending"
# Most active stocks (fast)
curl "http://localhost:18001/api/v1/stocks/most-active?limit=10"
# Top 52-week gainers (moderate)
curl "http://localhost:18001/api/v1/stocks/52-week-gainers?max_pages=1&limit=50"
# ETF holdings analysis
curl "http://localhost:18001/api/v1/etf/holdings/QQQ"
# Economic indicators from FRED (cached)
curl "http://localhost:18001/api/v1/fred/proxy/series?series_id=GDP"
curl "http://localhost:18001/api/v1/fred/proxy/series/observations?series_id=UNRATE&limit=12"
```
### Performance Comparison
```bash
# Fast queries (< 15 seconds)
curl "http://localhost:18001/api/v1/stocks/trending?n=200" # ~5-10s, 200 trending stocks (fast mode)
curl "http://localhost:18001/api/v1/stocks/most-active" # ~5s, 170 stocks
curl "http://localhost:18001/api/v1/stocks/52-week-gainers?max_pages=1" # ~8s, 200 stocks
# Moderate queries (15-35 seconds)
curl "http://localhost:18001/api/v1/stocks/trending" # ~15-30s, 500 trending stocks (default, recommended)
curl "http://localhost:18001/api/v1/stocks/52-week-gainers" # ~25s, 600 stocks (default)
# Comprehensive queries (35+ seconds)
curl "http://localhost:18001/api/v1/stocks/trending?n=1000" # ~30-60s, 1000 trending stocks (comprehensive)
curl "http://localhost:18001/api/v1/stocks/52-week-gainers?max_pages=7" # ~75s, 1350 stocks (all)
```
## 📊 API Usage
### Stock Market Data (NEW! 🔥)
#### Get Trending Stocks (🚀 Recommended)
```bash
# Get trending stocks (default: 500 total stocks)
curl "http://localhost:18001/api/v1/stocks/trending"
# Fast mode - 200 total stocks
curl "http://localhost:18001/api/v1/stocks/trending?n=200"
# Comprehensive mode - 1000 total stocks
curl "http://localhost:18001/api/v1/stocks/trending?n=1000"
# Custom mix - 50 most active + remaining gainers to reach 300 total
curl "http://localhost:18001/api/v1/stocks/trending?n=300&most_active_limit=50"
```
#### Trending Stocks Response Format
```json
{
"success": true,
"message": "Retrieved 500 trending stocks (170 most active + 330 gainers) in 18.5s",
"data": {
"trending_stocks": [
{
"symbol": "NVDA",
"company_name": "NVIDIA Corporation",
"current_price": "181.96",
"change_amount": "+0.42",
"change_percent": "+0.23%",
"volume": "45.2M",
"avg_volume": "42.1M",
"category": "most_active",
"rank_in_category": 1,
"scraped_at": "2025-01-14T18:30:15.123456"
},
{
"symbol": "TSLA",
"company_name": "Tesla Inc",
"current_price": "248.50",
"change_amount": "+12.30",
"change_percent": "+125.50%",
"volume": "2.1M",
"high_52w": "250.00",
"category": "52_week_gainer",
"rank_in_category": 1,
"scraped_at": "2025-01-14T18:30:15.123456"
},
{
"symbol": "AAPL",
"company_name": "Apple Inc",
"current_price": "174.50",
"change_amount": "+2.30",
"change_percent": "+1.33%",
"volume": "52.1M",
"avg_volume": "45.2M",
"high_52w": "199.62",
"category": "both",
"rank_in_category": 3,
"gainer_rank": 15,
"scraped_at": "2025-01-14T18:30:15.123456"
}
],
"summary": {
"total_stocks": 500,
"most_active_count": 170,
"gainers_count": 330,
"unique_symbols": 485,
"overlap_count": 15
},
"performance": {
"elapsed_time_seconds": 18.5,
"most_active_time": 3.1,
"gainers_time": 15.4,
"parallel_execution": true
},
"scraped_at": "2025-01-14T18:30:28.987654"
},
"metadata": {
"sources": [
"finance.yahoo.com/markets/stocks/most-active/",
"finance.yahoo.com/markets/stocks/52-week-gainers/"
],
"method": "parallel_scraping_with_intelligent_rate_limiting",
"categories": ["most_active", "52_week_gainer", "both"],
"rate_limit_bypass": "curl_cffi_chrome_impersonation",
"deduplication": "symbol_based_with_category_merge"
}
}
```
#### Key Features
**🔥 Trending Stocks (Recommended)**:
- **Best of Both Worlds**: Combines immediate market activity with long-term performance
- **Smart Deduplication**: Automatically merges overlapping stocks and marks as 'both'
- **Parallel Execution**: Fetches both datasets simultaneously for optimal performance
- **Flexible Configuration**: Customize limits for each category independently
- **Performance Tracking**: Real-time elapsed time and performance metrics
**📊 Categories**:
- **`most_active`**: High trading volume, immediate market attention
- **`52_week_gainer`**: Strong long-term price performance (up to 52 weeks)
- **`both`**: Stocks appearing in both categories (high activity + strong gains)
**⚡ Performance Modes**:
- **Fast Mode** (n=200): ~5-10 seconds, 200 total stocks
- **Default Mode** (n=500): ~15-30 seconds, 500 total stocks (recommended)
- **Comprehensive Mode** (n=1000+): ~30-60 seconds, 1000+ total stocks
#### Get Real-Time Most Active Stocks
```bash
# Get top 10 most active stocks
curl "http://localhost:18001/api/v1/stocks/most-active?limit=10"
# Get all available most active stocks (no limit)
curl "http://localhost:18001/api/v1/stocks/most-active"
```
#### Get 52-Week Top Gainers
```bash
# Get top 100 52-week gainers (fast, 1 page)
curl "http://localhost:18001/api/v1/stocks/52-week-gainers?limit=100&max_pages=1"
# Get default set (~600 gainers, 3 pages, recommended)
curl "http://localhost:18001/api/v1/stocks/52-week-gainers"
# Get first 1000 gainers (5 pages, slower but comprehensive)
curl "http://localhost:18001/api/v1/stocks/52-week-gainers?limit=1000&max_pages=5"
```
#### Most Active Stocks Response Format
```json
{
"success": true,
"message": "Retrieved 3 most active stocks",
"data": {
"stocks": [
{
"symbol": "NVDA",
"company_name": "NVIDIA Corporation",
"current_price": "181.81",
"price_change_raw": "181.81 +0.26 (+0.15%)",
"change_amount": "+0.26",
"change_percent": "+0.15%",
"volume": "93.425M",
"avg_volume": "184.951M",
"scraped_at": "2025-01-14T18:12:28.931780"
}
],
"total_available": 171,
"returned_count": 3,
"scraped_at": "2025-01-14T18:12:28.934007"
},
"metadata": {
"source": "finance.yahoo.com",
"endpoint": "markets/stocks/most-active",
"method": "web_scraping",
"rate_limit_bypass": "curl_cffi_chrome_impersonation"
}
}
```
#### 52-Week Gainers Response Format
```json
{
"success": true,
"message": "Retrieved all 400 52-week gaining stocks in 13.9s",
"data": {
"stocks": [
{
"symbol": "CLGPF",
"company_name": "Clean Seed Capital Group Ltd.",
"current_price": "0.1500",
"price_change_raw": "0.1500 +0.0750 (+100.00%)",
"change_amount": "+0.0750",
"change_percent": "+100.00%",
"volume": "25,000",
"avg_volume": "942",
"high_52w": "0.15",
"scraped_at": "2025-01-14T18:30:15.123456"
}
],
"total_available": 1350,
"returned_count": 400,
"pages_fetched": 2,
"scraped_at": "2025-01-14T18:30:28.987654",
"elapsed_time_seconds": 13.9
},
"metadata": {
"source": "finance.yahoo.com",
"endpoint": "markets/stocks/52-week-gainers",
"method": "intelligent_web_scraping",
"rate_limit_bypass": "curl_cffi_chrome_impersonation_with_smart_delays",
"requests_made": 5
}
}
```
#### Key Features
**🚀 Most Active Stocks**:
- **Real-Time Data**: Scraped directly from Yahoo Finance markets page
- **Complete Dataset**: Access to all ~170 most actively traded stocks
- **Fast Performance**: Sub-5 second response time
- **Rich Information**: Price, change, volume, and company details
**📈 52-Week Gainers**:
- **Comprehensive Data**: Access to 1,350+ top gaining stocks
- **Intelligent Rate Limiting**: Advanced delays to prevent blocking
- **Configurable Scope**: Choose 1-10 pages based on needs
- **Performance Metrics**: Real-time elapsed time tracking
- **Pagination Support**: Automatic multi-page handling
**🛡️ Rate Limiting Technology**:
- **curl_cffi + Chrome Impersonation**: Bypass standard rate limits
- **Smart Delays**: 1-3s base + 5s batch delays every 3 requests
- **Progressive Delays**: Increased delays for later pages
- **Session Management**: 5-minute session rotation
- **Error Recovery**: Automatic retry with exponential backoff
#### Performance Benchmarks
**Most Active Stocks**:
- **Response Time**: 3-5 seconds
- **Data Volume**: ~170 stocks (2 pages)
- **Success Rate**: 99.9%
- **Rate Limits**: Virtually eliminated
**52-Week Gainers**:
| Pages | Stocks | Time | Use Case |
|-------|--------|------|----------|
| 1 page | ~200 | 5-8s | Quick overview |
| 2 pages | ~400 | 12-15s | Moderate analysis |
| 3 pages | ~600 | 20-30s | **Recommended default** |
| 5 pages | ~1000 | 35-50s | Comprehensive analysis |
| 7 pages | ~1350 | 60-90s | Complete dataset |
**Rate Limiting Strategy**:
- **Base Delay**: 1-3 seconds (randomized)
- **Batch Delay**: 5+ seconds every 3 requests
- **Progressive Delay**: +0.5s per page after page 3
- **Session Rotation**: Every 5 minutes
- **Success Rate**: 99.5% even at scale
### FRED Economic Data (NEW! 🏦)
#### Universal FRED API Proxy (🚀 Recommended)
Access **ALL** FRED API endpoints through our pass-through proxy:
```bash
# Popular economic indicators
curl "http://localhost:18001/api/v1/fred/proxy/series?series_id=GDP" # GDP
curl "http://localhost:18001/api/v1/fred/proxy/series?series_id=UNRATE" # Unemployment Rate
curl "http://localhost:18001/api/v1/fred/proxy/series?series_id=FEDFUNDS" # Fed Funds Rate
curl "http://localhost:18001/api/v1/fred/proxy/series?series_id=CPIAUCSL" # Consumer Price Index
# Historical data with observations
curl "http://localhost:18001/api/v1/fred/proxy/series/observations?series_id=UNRATE&limit=12"
curl "http://localhost:18001/api/v1/fred/proxy/series/observations?series_id=GDP&observation_start=2020-01-01"
# Category data
curl "http://localhost:18001/api/v1/fred/proxy/category?category_id=125"
curl "http://localhost:18001/api/v1/fred/proxy/category/children?category_id=13"
# Release information
curl "http://localhost:18001/api/v1/fred/proxy/release?release_id=53"
curl "http://localhost:18001/api/v1/fred/proxy/releases"
# Search functionality
curl "http://localhost:18001/api/v1/fred/proxy/series/search?search_text=unemployment&limit=25"
# Sources and tags
curl "http://localhost:18001/api/v1/fred/proxy/sources"
curl "http://localhost:18001/api/v1/fred/proxy/tags?limit=100"
# System information
curl "http://localhost:18001/api/v1/fred/endpoints" # Discover all available endpoints
curl "http://localhost:18001/api/v1/fred/stats/usage" # Monitor API usage (1000/day limit)
```
#### FRED Response Format
```json
{
"success": true,
"data": {
"id": "GDP",
"title": "Gross Domestic Product",
"units": "Billions of Dollars",
"frequency": "Quarterly",
"last_updated": "2025-07-30T07:56:35",
"cached": true,
"cached_at": "2025-01-14T10:30:00"
},
"metadata": {
"source": "fred.stlouisfed.org",
"cache_duration_hours": 24,
"daily_api_limit": 1000
}
}
```
#### FRED Features
**🚀 Universal Proxy Access (NEW!)**:
- **Complete FRED API Coverage**: Access to ALL FRED endpoints via proxy
- **Pass-through Architecture**: Direct forwarding with rate limiting
- **Parameter Auto-mapping**: Automatic parameter handling for all endpoints
- **Enhanced Statistics**: Endpoint-specific usage tracking
**🏦 Smart Caching System**:
- **24-hour cache duration** for series and observations
- **Database persistence** with SQLite/PostgreSQL
- **Automatic cache invalidation** after expiry
- **Cache-first strategy** to minimize API calls
**📊 Daily Limit Management**:
- **1,000 API calls per day** (FRED limitation)
- **Usage tracking** with detailed statistics
- **Graceful degradation** when limit reached
- **Cache fallback** for expired data when limit hit
**⚡ Performance Optimization**:
- **Sub-second response** for cached data
- **2-5 second response** for fresh API calls
- **Batch operations** for multiple series
- **Usage monitoring** and optimization suggestions
**🔧 Dual Access Methods**:
- **Direct Endpoints**: Optimized for series and observations with caching
- **Proxy Endpoints**: Universal access to all FRED functionality
- **Automatic Fallback**: Seamless switching between methods
### ETF Holdings Data
#### Get Current ETF Holdings
```bash
# Get latest holdings for QQQ
curl "http://localhost:18001/api/v1/etf/holdings/QQQ"
# Get holdings for specific date
curl "http://localhost:18001/api/v1/etf/holdings/QQQM?as_of_date=2024-01-01"
# Without detailed holdings (metadata only)
curl "http://localhost:18001/api/v1/etf/holdings/SPY?include_holdings=false"
```
#### Key Features
- **Automatic CIK Lookup**: No need to know CIK numbers - just use ticker symbols
- **Historical Data Support**: Access NPORT filings from 2019 onwards
- **Date Validation**: Automatically checks if ETF existed on requested date
- **Availability Info**: Returns available date ranges when data not found
- **Fast Performance**: <0.1s response time with launch date caching
#### Response with Availability Information
```json
{
"ticker": "QQQM",
"as_of_date": "2020-01-01",
"success": false,
"error": "ETF QQQM did not exist on 2020-01-01. Launched on 2020-10-13",
"availability": {
"exists_for_date": false,
"etf_launch_date": "2020-10-13",
"first_nport_date": "2021-01-31",
"available_date_range": {
"start": "2021-01-31",
"end": "present"
}
}
}
```
#### Supported ETFs
Major ETFs with pre-configured mappings:
- **Invesco**: QQQ, QQQM, XLG
- **SPDR**: SPY, XLF, XLE, XLK, XLV, XLI
- **iShares**: IWM, EFA, EEM, TLT, AGG, SLV, MTUM
- **Vanguard**: VTI, VOO, VEA, VWO, BND
- **ARK**: ARKK, ARKQ, ARKW, ARKG, ARKF
- And many more...
### Get Financial Data
#### 🔥 Three Ways to Specify Time Period:
1. **Period String** (NEW! Most convenient):
```bash
# Last 1 year of data (excludes today for data availability)
curl "http://localhost:18001/api/v1/financial/data/AAPL?period=1y"
# Using POST
curl -X POST "http://localhost:18001/api/v1/financial/data" \
-H "Content-Type: application/json" \
-d '{"ticker": "AAPL", "period": "1y"}'
```
2. **Date Range** (Traditional):
```bash
# Specific date range
curl "http://localhost:18001/api/v1/financial/data/AAPL?start_date=2023-01-01&end_date=2023-12-31"
# Using POST
curl -X POST "http://localhost:18001/api/v1/financial/data" \
-H "Content-Type: application/json" \
-d '{
"ticker": "AAPL",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"period_type": "quarterly",
"include_metrics": true,
"force_refresh": false
}'
```
3. **Quarters** (Quarter-based):
```bash
# Using POST
curl -X POST "http://localhost:18001/api/v1/financial/data" \
-H "Content-Type: application/json" \
-d '{
"ticker": "AAPL",
"quarters": ["2024Q1", "2024Q2", "2024Q3"]
}'
```
⚠️ **IMPORTANT**: Period parameters now use **yesterday** as end date to ensure data availability since today's data might not be available yet.
### Get News & Social Media Data (NEW! 🆕)
Get comprehensive news and social media sentiment data for any ticker:
```bash
# Get complete news and social media data
curl "http://localhost:18001/api/v1/news/AAPL?days_back=7&max_articles=20&include_social=true"
# Get news only (faster response)
curl "http://localhost:18001/api/v1/news/TSLA/news-only?days_back=5&max_articles=30"
# Get social media only
curl "http://localhost:18001/api/v1/news/NVDA/social-only?days_back=3&max_social_posts=15"
```
#### Response Format
```json
{
"ticker": "AAPL",
"retrieved_at": "2025-08-10T17:40:04.781906",
"news": {
"total_articles": 12,
"sources": {"yahoo_finance": 6, "newsapi": 6},
"articles": [
{
"title": "Apple Reports Strong Q3 Results",
"summary": "Apple exceeded expectations...",
"url": "https://finance.yahoo.com/...",
"source": "Yahoo Finance",
"published_at": "2025-08-10T14:30:00",
"author": "John Smith"
}
]
},
"social_media": {
"total_posts": 8,
"platforms": {"reddit": 8},
"posts": [
{
"title": "$AAPL breakout incoming?",
"content": "Technical analysis shows...",
"url": "https://reddit.com/r/stocks/...",
"platform": "Reddit",
"author": "trader123",
"score": 245,
"comments_count": 67,
"subreddit": "stocks"
}
]
},
"summary": {
"total_items": 20,
"time_range_days": 7,
"newest_item": "2025-08-10T14:30:00",
"oldest_item": "2025-08-03T09:15:00"
}
}
```
#### Query Parameters
- `days_back`: Number of days to look back (1-30, default: 7)
- `max_articles`: Maximum news articles to return (1-100, default: 20)
- `max_social_posts`: Maximum social posts to return (1-100, default: 15)
- `include_social`: Include social media data (true/false, default: true)
### Available Endpoints
#### Stock Market Data (NEW! 🔥)
- `GET /api/v1/stocks/trending` - **Trending stocks combining most active + 52-week gainers** (🚀 Recommended)
- `GET /api/v1/stocks/most-active` - Most actively traded stocks (optional limit parameter)
- `GET /api/v1/stocks/52-week-gainers` - 52-week top gaining stocks with intelligent rate limiting
#### FRED Economic Data (NEW! 🏦)
- `GET /api/v1/fred/proxy/{endpoint:path}` - **Universal FRED API proxy with caching** (🚀 Recommended)
- `GET /api/v1/fred/endpoints` - List all supported FRED API endpoints
- `GET /api/v1/fred/stats/usage` - API usage statistics and daily limit monitoring
#### ETF Holdings
- `GET /api/v1/etf/holdings/{ticker}` - Get ETF holdings with date support
- `POST /api/v1/etf/admin/refresh-maps` - Refresh ETF CIK mappings
#### Financial Data
- `GET /api/v1/financial/data/{ticker}` - Simple financial data with query parameters
- `POST /api/v1/financial/data` - Detailed financial data request
- `POST /api/v1/financial/data/bulk` - Bulk financial data for multiple tickers
#### Price Data
- `GET /api/v1/price/data/{ticker}` - Simple price data with query parameters
- `POST /api/v1/price/data` - Detailed price data request
- `POST /api/v1/price/data/bulk` - Bulk price data for multiple tickers
#### News & Social Media
- `GET /api/v1/news/{ticker}` - Complete news and social media data
- `GET /api/v1/news/{ticker}/news-only` - News articles only (faster)
- `GET /api/v1/news/{ticker}/social-only` - Social media posts only
#### System & Admin
- `GET /api/v1/health` - API health check
- `GET /api/v1/metadata/catalog` - Data field catalog
- `POST /api/v1/admin/migrate` - Database migration
- `GET /api/v1/admin/errors/logs` - Error log management
- `GET /api/v1/admin/errors/stats` - Error statistics
#### Frontend
- Frontend: http://localhost:18002 (when using Docker)
- Error Log Viewer: http://localhost:18002/errors
## 🐍 Python Client Usage
### Installation
```python
# Copy the client file to your project
# stock_oracle_client.py is included in the repository
```
### Basic Usage
```python
from stock_oracle_client import StockOracleClient
# Initialize client
client = StockOracleClient("http://localhost:18001")
# Check API health
health = client.get_health()
print("API Status:", health["status"])
# Get financial data using period (recommended)
data = client.get_financial_data("AAPL", period="1y")
print(f"Found {len(data['financial_data'])} quarters of data")
# Get financial data using date range
data = client.get_financial_data(
"MSFT",
start_date="2023-01-01",
end_date="2023-12-31",
period_type="quarterly"
)
# Get financial data using quarters
data = client.get_financial_data(
"GOOGL",
quarters=["2024Q1", "2024Q2", "2024Q3"]
)
# Get price data (period automatically excludes today's data)
prices = client.get_price_data("AAPL", period="30d", interval="1d")
# Get news and social media data (NEW!)
news_data = client.get_news_social_data(
ticker="AAPL",
days_back=7,
max_articles=20,
include_social=True
)
# Get news only (faster response)
news_only = client.get_news_only("TSLA", days_back=5, max_articles=30)
# Get social media only
social_only = client.get_social_only("NVDA", days_back=3, max_social_posts=15)
# Bulk operations
bulk_data = client.get_bulk_financial_data(
tickers=["AAPL", "MSFT", "GOOGL"],
period="2y"
)
```
### Period Options
- **Days**: "1d", "7d", "30d"
- **Months**: "1m", "3m", "6m"
- **Years**: "1y", "2y", "5y", "10y"
⚠️ **Note**: Period parameters automatically use **yesterday** as end date for data availability.
## 🔧 Configuration
### Environment Variables
```bash
# Application
APP_NAME=Stock_Oracle
API_PREFIX=/api/v1
# Database
DATABASE_URL=sqlite+aiosqlite:///./stock_oracle.db
# SEC Data
SEC_EMAIL=your@email.com # Required for SEC API access
# Cache
REDIS_URL=redis://localhost:16379/0 # Use redis://redis:6379/0 in Docker
CACHE_TTL=3600 # Response cache TTL in seconds (default: 3600)
# Server Ports
API_PORT=18000
DB_PORT=15432 # PostgreSQL (if used)
REDIS_PORT=16379 # Redis cache
```
### Docker Ports
- **API**: 18001 (external) → 18000 (internal)
- **Frontend**: 18002 (external) → 3000 (internal)
- **PostgreSQL**: 15433 (external) → 5432 (internal)
- **Redis**: 16380 (external) → 6379 (internal)
## 📈 Supported Metrics
### ✅ Implemented (13/16)
- Market Cap
- P/E Ratio (Trailing)
- P/B Ratio
- Debt-to-Equity
- Return on Equity (ROE)
- Return on Assets (ROA)
- Revenue Growth (YoY)
- Earnings Growth (YoY)
- Gross Margins
- Operating Margins
- Profit Margins
- Sector Classification
- Industry Classification
### ❌ Requires External Data (3/16)
- Forward P/E (analyst estimates needed)
- PEG Ratio (growth estimates needed)
- Beta (market correlation data needed)
## 🧪 Testing
```bash
# Run all tests
python -m pytest tests/ -v
# Run simple functionality tests
python tests/test_simple.py
# Run specific test categories
python -m pytest tests/test_financial.py -v
python -m pytest tests/test_integration.py -v
```
## ⚡ Server-side Response Caching (NEW)
Stock Oracle now supports Redis-backed response caching for the most frequently used single-ticker endpoints.
### Targets
- `POST /api/v1/price/data`
- `GET /api/v1/price/data/{ticker}` (internally uses the same logic)
- `POST /api/v1/financial/data`
- `GET /api/v1/financial/data/{ticker}` (internally uses the same logic)
Bulk endpoints are not cached.
### Behavior
- Cache store: Redis (`REDIS_URL`)
- TTL: `CACHE_TTL` seconds
- Bypass/refresh: set `force_refresh=true` in the request body or query
- Response headers:
- `X-Cache`: `HIT` or `MISS`
- `ETag`: strong hash for the response body
- `Cache-Control`: `public, max-age={CACHE_TTL}`
- `X-Data-Source`: `redis-cache` (price endpoint에서 캐시 히트 시)
### Quick checks
```bash
# 1) MISS (store in cache)
curl -s -X POST "http://localhost:18001/api/v1/price/data" \
-H "Content-Type: application/json" \
-d '{"ticker":"AAPL","period":"3m","interval":"1d"}' -i | grep -Ei 'x-cache|etag|cache-control|x-data-source'
# 2) HIT (served from cache)
curl -s -X POST "http://localhost:18001/api/v1/price/data" \
-H "Content-Type: application/json" \
-d '{"ticker":"AAPL","period":"3m","interval":"1d"}' -i | grep -Ei 'x-cache|etag|cache-control|x-data-source'
# Force fresh fetch, bypass cache
curl -s -X POST "http://localhost:18001/api/v1/price/data" \
-H "Content-Type: application/json" \
-d '{"ticker":"AAPL","period":"3m","interval":"1d","force_refresh":true}' -i | grep -Ei 'x-cache|etag|cache-control|x-data-source'
```
Notes:
- If Redis is unreachable, the API gracefully continues without caching.
- Adjust `REDIS_URL` appropriately (Docker: `redis://redis:6379/0`).
## 📁 Project Structure
```
stock-oracle/
├── app/
│ ├── api/v1/endpoints/ # API route handlers
│ ├── core/ # Configuration and database
│ ├── models/ # SQLAlchemy database models
│ ├── schemas/ # Pydantic data validation
│ ├── services/ # Business logic services
│ └── main.py # FastAPI application entry
├── tests/ # Comprehensive test suite
├── scripts/ # Database initialization
├── data/ # SQLite database storage
├── docker-compose.yml # Docker orchestration
├── Dockerfile # Container definition
├── requirements-*.txt # Python dependencies
├── stock_oracle_analyzer.py # Core analysis engine
└── .env # Environment configuration
```
## 🔐 Security & Production
### Security Features
- Input validation and sanitization
- SQL injection prevention
- Rate limiting (configurable)
- Environment-based configuration
- Secure secret management
### Production Deployment
1. **Database**: Switch to PostgreSQL for production
2. **Secrets**: Use proper secret management (not .env files)
3. **Monitoring**: Add application monitoring and logging
4. **Scaling**: Use container orchestration (Kubernetes, Docker Swarm)
5. **SSL**: Enable HTTPS with proper certificates
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests for new functionality
5. Ensure all tests pass
6. Submit a pull request
## 📄 License
[Your License Here]
## 🆘 Support
- **Documentation**: Check `/docs` endpoint for interactive API docs
- **Issues**: Report bugs and feature requests in the issue tracker
- **Email**: [your-support-email]
---
**Stock Oracle** - Empowering investment decisions with comprehensive SEC data analysis 🔮📈

@ -0,0 +1 @@
# FastAPI SEC Investment API

@ -0,0 +1 @@
# API module

@ -0,0 +1,23 @@
"""
API v1 router
"""
from fastapi import APIRouter
from app.api.v1.endpoints import financial, price, catalog, health, migration, database, error_logs, request_logs, news, etf, stocks, fred
api_router = APIRouter()
# Include all endpoint routers
api_router.include_router(health.router, tags=["health"])
api_router.include_router(financial.router, prefix="/financial", tags=["financial"])
api_router.include_router(price.router, prefix="/price", tags=["price"])
api_router.include_router(stocks.router, prefix="/stocks", tags=["stocks"])
api_router.include_router(fred.router, prefix="/fred", tags=["fred"])
api_router.include_router(news.router, prefix="/news", tags=["news"])
api_router.include_router(etf.router, prefix="/etf", tags=["etf"])
api_router.include_router(catalog.router, prefix="/metadata", tags=["metadata"])
api_router.include_router(migration.router, prefix="/admin", tags=["admin"])
# Removed docs.router - documentation now served at root path
api_router.include_router(database.router, prefix="/database", tags=["database"])
api_router.include_router(error_logs.router, prefix="/admin/errors", tags=["error-logs"])
api_router.include_router(request_logs.router, prefix="/admin/requests", tags=["request-logs"])

@ -0,0 +1,3 @@
from app.api.v1.endpoints import financial, price, catalog, health, migration
__all__ = ["financial", "price", "catalog", "health", "migration"]

@ -0,0 +1,406 @@
"""
Data catalog endpoint
"""
from datetime import datetime
from typing import Dict, List
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.schemas.financial import DataCatalogResponse, DataCatalogItem
router = APIRouter()
def get_data_catalog() -> Dict[str, List[DataCatalogItem]]:
"""Get comprehensive data catalog"""
catalog = {
"Company Information": [
DataCatalogItem(
field_name="ticker",
description="Stock ticker symbol",
data_type="string",
unit=None,
calculation=None,
source="SEC EDGAR"
),
DataCatalogItem(
field_name="name",
description="Company legal name",
data_type="string",
unit=None,
calculation=None,
source="SEC EDGAR"
),
DataCatalogItem(
field_name="cik",
description="Central Index Key - SEC's unique identifier",
data_type="string",
unit=None,
calculation=None,
source="SEC EDGAR"
),
DataCatalogItem(
field_name="sector",
description="Business sector classification",
data_type="string",
unit=None,
calculation=None,
source="SEC EDGAR / External"
),
DataCatalogItem(
field_name="industry",
description="Industry classification",
data_type="string",
unit=None,
calculation=None,
source="SEC EDGAR / External"
)
],
"Income Statement": [
DataCatalogItem(
field_name="revenue",
description="Total revenue/sales for the period",
data_type="float",
unit="USD",
calculation=None,
source="SEC EDGAR 10-K/10-Q"
),
DataCatalogItem(
field_name="gross_profit",
description="Revenue minus cost of goods sold",
data_type="float",
unit="USD",
calculation="Revenue - COGS",
source="SEC EDGAR 10-K/10-Q"
),
DataCatalogItem(
field_name="operating_income",
description="Gross profit minus operating expenses",
data_type="float",
unit="USD",
calculation="Gross Profit - Operating Expenses",
source="SEC EDGAR 10-K/10-Q"
),
DataCatalogItem(
field_name="net_income",
description="Bottom line profit after all expenses and taxes",
data_type="float",
unit="USD",
calculation="Operating Income - Interest - Taxes",
source="SEC EDGAR 10-K/10-Q"
),
DataCatalogItem(
field_name="eps",
description="Earnings per share - basic",
data_type="float",
unit="USD per share",
calculation="Net Income / Shares Outstanding",
source="SEC EDGAR 10-K/10-Q"
)
],
"Balance Sheet": [
DataCatalogItem(
field_name="total_assets",
description="Total value of everything the company owns",
data_type="float",
unit="USD",
calculation="Current Assets + Non-Current Assets",
source="SEC EDGAR 10-K/10-Q"
),
DataCatalogItem(
field_name="total_equity",
description="Shareholders' equity (Assets - Liabilities)",
data_type="float",
unit="USD",
calculation="Total Assets - Total Liabilities",
source="SEC EDGAR 10-K/10-Q"
),
DataCatalogItem(
field_name="total_debt",
description="Long-term debt obligations",
data_type="float",
unit="USD",
calculation=None,
source="SEC EDGAR 10-K/10-Q"
),
DataCatalogItem(
field_name="cash",
description="Cash and cash equivalents",
data_type="float",
unit="USD",
calculation=None,
source="SEC EDGAR 10-K/10-Q"
),
DataCatalogItem(
field_name="shares_outstanding",
description="Number of shares currently held by shareholders",
data_type="float",
unit="shares",
calculation=None,
source="SEC EDGAR 10-K/10-Q"
)
],
"Cash Flow Statement": [
DataCatalogItem(
field_name="operating_cash_flow",
description="Cash generated from core business operations",
data_type="float",
unit="USD",
calculation="Net Income + Non-cash adjustments + Working Capital changes",
source="SEC EDGAR 10-K/10-Q"
),
DataCatalogItem(
field_name="free_cash_flow",
description="Cash available after capital expenditures",
data_type="float",
unit="USD",
calculation="Operating Cash Flow - Capital Expenditures",
source="SEC EDGAR 10-K/10-Q"
),
DataCatalogItem(
field_name="capex",
description="Capital expenditures for property, plant, and equipment",
data_type="float",
unit="USD",
calculation=None,
source="SEC EDGAR 10-K/10-Q"
)
],
"Valuation Ratios": [
DataCatalogItem(
field_name="pe_ratio",
description="Price-to-Earnings ratio (trailing)",
data_type="float",
unit="ratio",
calculation="Stock Price / EPS",
source="Calculated"
),
DataCatalogItem(
field_name="pb_ratio",
description="Price-to-Book ratio",
data_type="float",
unit="ratio",
calculation="Market Cap / Total Equity",
source="Calculated"
),
DataCatalogItem(
field_name="ps_ratio",
description="Price-to-Sales ratio",
data_type="float",
unit="ratio",
calculation="Market Cap / Revenue",
source="Calculated"
),
DataCatalogItem(
field_name="ev_ebitda",
description="Enterprise Value to EBITDA ratio",
data_type="float",
unit="ratio",
calculation="Enterprise Value / EBITDA",
source="Calculated"
)
],
"Profitability Metrics": [
DataCatalogItem(
field_name="roe",
description="Return on Equity",
data_type="float",
unit="percentage",
calculation="(Net Income / Total Equity) × 100",
source="Calculated"
),
DataCatalogItem(
field_name="roa",
description="Return on Assets",
data_type="float",
unit="percentage",
calculation="(Net Income / Total Assets) × 100",
source="Calculated"
),
DataCatalogItem(
field_name="gross_margin",
description="Gross profit margin",
data_type="float",
unit="percentage",
calculation="(Gross Profit / Revenue) × 100",
source="Calculated"
),
DataCatalogItem(
field_name="operating_margin",
description="Operating profit margin",
data_type="float",
unit="percentage",
calculation="(Operating Income / Revenue) × 100",
source="Calculated"
),
DataCatalogItem(
field_name="net_margin",
description="Net profit margin",
data_type="float",
unit="percentage",
calculation="(Net Income / Revenue) × 100",
source="Calculated"
)
],
"Growth Metrics": [
DataCatalogItem(
field_name="revenue_growth_yoy",
description="Year-over-year revenue growth rate",
data_type="float",
unit="percentage",
calculation="((Current Year Revenue - Previous Year Revenue) / Previous Year Revenue) × 100",
source="Calculated"
),
DataCatalogItem(
field_name="revenue_growth_qoq",
description="Quarter-over-quarter revenue growth rate",
data_type="float",
unit="percentage",
calculation="((Current Quarter Revenue - Previous Quarter Revenue) / Previous Quarter Revenue) × 100",
source="Calculated"
),
DataCatalogItem(
field_name="eps_growth_yoy",
description="Year-over-year EPS growth rate",
data_type="float",
unit="percentage",
calculation="((Current Year EPS - Previous Year EPS) / Previous Year EPS) × 100",
source="Calculated"
)
],
"Liquidity & Solvency": [
DataCatalogItem(
field_name="debt_to_equity",
description="Debt-to-Equity ratio",
data_type="float",
unit="ratio",
calculation="Total Debt / Total Equity",
source="Calculated"
),
DataCatalogItem(
field_name="debt_to_assets",
description="Debt-to-Assets ratio",
data_type="float",
unit="ratio",
calculation="Total Debt / Total Assets",
source="Calculated"
),
DataCatalogItem(
field_name="current_ratio",
description="Current assets to current liabilities ratio",
data_type="float",
unit="ratio",
calculation="Current Assets / Current Liabilities",
source="Calculated"
)
],
"Efficiency Metrics": [
DataCatalogItem(
field_name="asset_turnover",
description="How efficiently company uses assets to generate revenue",
data_type="float",
unit="ratio",
calculation="Revenue / Average Total Assets",
source="Calculated"
),
DataCatalogItem(
field_name="ocf_margin",
description="Operating cash flow margin",
data_type="float",
unit="percentage",
calculation="(Operating Cash Flow / Revenue) × 100",
source="Calculated"
),
DataCatalogItem(
field_name="fcf_margin",
description="Free cash flow margin",
data_type="float",
unit="percentage",
calculation="(Free Cash Flow / Revenue) × 100",
source="Calculated"
)
],
"Market Data (Future)": [
DataCatalogItem(
field_name="market_cap",
description="Market capitalization",
data_type="float",
unit="USD",
calculation="Stock Price × Shares Outstanding",
source="Calculated (requires price data)"
),
DataCatalogItem(
field_name="forward_pe",
description="Forward P/E ratio based on estimated earnings",
data_type="float",
unit="ratio",
calculation="Stock Price / Forward EPS Estimate",
source="External data required"
),
DataCatalogItem(
field_name="peg_ratio",
description="Price/Earnings to Growth ratio",
data_type="float",
unit="ratio",
calculation="P/E Ratio / Earnings Growth Rate",
source="External data required"
),
DataCatalogItem(
field_name="beta",
description="Stock's volatility relative to market",
data_type="float",
unit="coefficient",
calculation="Covariance(Stock Returns, Market Returns) / Variance(Market Returns)",
source="External price data required"
)
]
}
return catalog
@router.get(
"/catalog",
response_model=DataCatalogResponse,
summary="Get data catalog",
description="""
Get a comprehensive catalog of all available data fields.
This endpoint returns:
- All available financial metrics and their descriptions
- Data types and units for each field
- Calculation methods where applicable
- Data sources for each field
The catalog is organized by categories:
- Company Information
- Income Statement
- Balance Sheet
- Cash Flow Statement
- Valuation Ratios
- Profitability Metrics
- Growth Metrics
- Liquidity & Solvency
- Efficiency Metrics
- Market Data (Future)
"""
)
async def get_catalog(db: AsyncSession = Depends(get_db)):
"""Get comprehensive data catalog"""
catalog = get_data_catalog()
return DataCatalogResponse(
categories=catalog,
last_updated=datetime.utcnow()
)

@ -0,0 +1,473 @@
"""
Database statistics and status endpoints
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, distinct, text
from typing import Dict, Any, List
import logging
from datetime import datetime
from app.core.database import get_db
from app.models.financial import Company, FinancialData, CalculatedMetrics, PriceData
from app.models.etf import ETFHoldingsSnapshot, ETFHolding
from app.schemas.financial import DataSource
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/stats")
async def get_database_stats(db: AsyncSession = Depends(get_db)) -> Dict[str, Any]:
"""
데이터베이스 통계 정보를 반환합니다.
Returns:
Dict containing database statistics including:
- companies: total, with_financial_data, with_price_data
- financial_data: total_records, real_data, estimated_data, date_range, by_source
- price_data: total_records, date_range, tickers
- calculated_metrics: total_records, date_range
"""
try:
logger.info("Fetching database statistics")
# 회사 통계
companies_total = await db.execute(select(func.count(Company.id)))
companies_total = companies_total.scalar()
# 재무 데이터가 있는 회사 수
companies_with_financial = await db.execute(
select(func.count(distinct(FinancialData.ticker)))
)
companies_with_financial = companies_with_financial.scalar()
# 주가 데이터가 있는 회사 수
companies_with_price = await db.execute(
select(func.count(distinct(PriceData.ticker)))
)
companies_with_price = companies_with_price.scalar()
# 재무 데이터 통계
financial_total = await db.execute(select(func.count(FinancialData.id)))
financial_total = financial_total.scalar()
financial_real = await db.execute(
select(func.count(FinancialData.id)).where(FinancialData.is_estimated == False)
)
financial_real = financial_real.scalar()
financial_estimated = await db.execute(
select(func.count(FinancialData.id)).where(FinancialData.is_estimated == True)
)
financial_estimated = financial_estimated.scalar()
# 재무 데이터 날짜 범위
financial_date_range = await db.execute(
select(
func.min(FinancialData.period_date),
func.max(FinancialData.period_date)
)
)
financial_dates = financial_date_range.first()
# 데이터 소스별 분포
financial_by_source = await db.execute(
select(
FinancialData.data_source,
func.count(FinancialData.id)
).group_by(FinancialData.data_source)
)
source_distribution = {source: count for source, count in financial_by_source.all()}
# 주가 데이터 통계
price_total = await db.execute(select(func.count(PriceData.id)))
price_total = price_total.scalar()
# 주가 데이터 날짜 범위
price_date_range = await db.execute(
select(
func.min(PriceData.date),
func.max(PriceData.date)
)
)
price_dates = price_date_range.first()
# 주가 데이터 종목 목록
price_tickers = await db.execute(
select(distinct(PriceData.ticker)).order_by(PriceData.ticker)
)
ticker_list = [ticker for ticker, in price_tickers.all()]
# 계산된 지표 통계
metrics_total = await db.execute(select(func.count(CalculatedMetrics.id)))
metrics_total = metrics_total.scalar()
# 계산된 지표 날짜 범위
metrics_date_range = await db.execute(
select(
func.min(CalculatedMetrics.period_date),
func.max(CalculatedMetrics.period_date)
)
)
metrics_dates = metrics_date_range.first()
# 결과 구성
stats = {
"companies": {
"total": companies_total or 0,
"with_financial_data": companies_with_financial or 0,
"with_price_data": companies_with_price or 0
},
"financial_data": {
"total_records": financial_total or 0,
"real_data": financial_real or 0,
"estimated_data": financial_estimated or 0,
"date_range": {
"earliest": financial_dates[0].isoformat() if financial_dates[0] else None,
"latest": financial_dates[1].isoformat() if financial_dates[1] else None
},
"by_source": source_distribution
},
"price_data": {
"total_records": price_total or 0,
"date_range": {
"earliest": price_dates[0].isoformat() if price_dates[0] else None,
"latest": price_dates[1].isoformat() if price_dates[1] else None
},
"tickers": ticker_list
},
"calculated_metrics": {
"total_records": metrics_total or 0,
"date_range": {
"earliest": metrics_dates[0].isoformat() if metrics_dates[0] else None,
"latest": metrics_dates[1].isoformat() if metrics_dates[1] else None
}
}
}
logger.info(f"Successfully fetched database statistics: {financial_total} financial records, {price_total} price records")
return stats
except Exception as e:
logger.error(f"Error fetching database statistics: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to fetch database statistics: {str(e)}"
)
@router.get("/health")
async def get_database_health(db: AsyncSession = Depends(get_db)) -> Dict[str, Any]:
"""
데이터베이스 연결 상태를 확인합니다.
"""
try:
# 간단한 쿼리를 실행하여 연결 상태 확인
result = await db.execute(select(1))
result.scalar()
return {
"status": "healthy",
"database": "connected",
"timestamp": "2025-08-02T12:00:00Z"
}
except Exception as e:
logger.error(f"Database health check failed: {str(e)}")
raise HTTPException(
status_code=503,
detail=f"Database connection failed: {str(e)}"
)
@router.get("/tables")
async def get_table_info(db: AsyncSession = Depends(get_db)) -> Dict[str, Any]:
"""
데이터베이스 테이블 정보를 반환합니다.
"""
try:
table_info = {}
# 각 테이블의 레코드 수 조회
tables = [
("companies", Company),
("financial_data", FinancialData),
("price_data", PriceData),
("calculated_metrics", CalculatedMetrics)
]
for table_name, model in tables:
count = await db.execute(select(func.count(model.id)))
table_info[table_name] = {
"record_count": count.scalar() or 0,
"table_name": table_name
}
return {
"tables": table_info,
"total_tables": len(table_info)
}
except Exception as e:
logger.error(f"Error fetching table info: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to fetch table info: {str(e)}"
)
@router.post("/cleanup/duplicates")
async def cleanup_duplicate_records(db: AsyncSession = Depends(get_db)) -> Dict[str, Any]:
"""
Remove duplicate financial and metrics records, keeping the most recent real data.
"""
try:
logger.info("Starting duplicate record cleanup")
# Start transaction
async with db.begin():
# Clean financial data duplicates - keep most recent real data
financial_cleanup = await db.execute(text("""
DELETE FROM financial_data
WHERE id NOT IN (
SELECT DISTINCT ON (ticker, period_date, period_type) id
FROM financial_data
ORDER BY ticker, period_date, period_type,
CASE WHEN is_estimated = false THEN 0 ELSE 1 END, -- Real data first
created_at DESC -- Most recent first
)
"""))
# Clean calculated metrics duplicates
metrics_cleanup = await db.execute(text("""
DELETE FROM calculated_metrics
WHERE id NOT IN (
SELECT DISTINCT ON (ticker, period_date) id
FROM calculated_metrics
ORDER BY ticker, period_date, created_at DESC
)
"""))
await db.commit()
logger.info(f"Duplicate cleanup completed")
return {
"status": "success",
"message": "Duplicate records cleaned up successfully",
"timestamp": datetime.now().isoformat()
}
except Exception as e:
logger.error(f"Error cleaning up duplicates: {str(e)}")
await db.rollback()
raise HTTPException(
status_code=500,
detail=f"Failed to cleanup duplicates: {str(e)}"
)
@router.get("/tickers")
async def get_available_tickers(db: AsyncSession = Depends(get_db)) -> Dict[str, List[str]]:
"""
사용 가능한 종목 목록을 반환합니다.
"""
try:
# 재무 데이터가 있는 종목들
financial_tickers = await db.execute(
select(distinct(FinancialData.ticker)).order_by(FinancialData.ticker)
)
financial_list = [ticker for ticker, in financial_tickers.all()]
# 주가 데이터가 있는 종목들
price_tickers = await db.execute(
select(distinct(PriceData.ticker)).order_by(PriceData.ticker)
)
price_list = [ticker for ticker, in price_tickers.all()]
# 모든 종목들
all_tickers = await db.execute(
select(Company.ticker).order_by(Company.ticker)
)
all_list = [ticker for ticker, in all_tickers.all()]
return {
"tickers": list(set(financial_list + price_list + all_list)),
"financial_tickers": financial_list,
"price_tickers": price_list,
"all_tickers": all_list
}
except Exception as e:
logger.error(f"Error fetching available tickers: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to fetch available tickers: {str(e)}"
)
# ==========================
# ETF persisted data browsing
# ==========================
@router.get("/etf/snapshots")
async def list_etf_snapshots(
ticker: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
limit: int = 50,
offset: int = 0,
db: AsyncSession = Depends(get_db),
):
try:
from sqlalchemy import and_, desc
q = select(ETFHoldingsSnapshot)
conditions = []
if ticker:
conditions.append(ETFHoldingsSnapshot.ticker == ticker.upper())
if start_date:
from datetime import datetime, timezone
try:
sd = datetime.fromisoformat(start_date)
if sd.tzinfo is None:
sd = sd.replace(tzinfo=timezone.utc)
conditions.append(ETFHoldingsSnapshot.snapshot_date >= sd)
except Exception:
pass
if end_date:
from datetime import datetime, timezone
try:
ed = datetime.fromisoformat(end_date)
if ed.tzinfo is None:
ed = ed.replace(tzinfo=timezone.utc)
conditions.append(ETFHoldingsSnapshot.snapshot_date <= ed)
except Exception:
pass
if conditions:
from sqlalchemy import and_ as _and
q = q.where(_and(*conditions))
q = q.order_by(desc(ETFHoldingsSnapshot.snapshot_date)).limit(limit).offset(offset)
res = await db.execute(q)
rows = res.scalars().all()
# Count holdings per snapshot
data = []
for s in rows:
cnt_res = await db.execute(select(func.count(ETFHolding.id)).where(ETFHolding.snapshot_id == s.id))
hcount = cnt_res.scalar() or 0
data.append({
"id": str(s.id),
"ticker": s.ticker,
"snapshot_date": s.snapshot_date.isoformat() if s.snapshot_date else None,
"source": s.source,
"cik": s.cik,
"filing_accession": s.filing_accession,
"xml_url": s.xml_url,
"holdings_count": hcount,
})
return {"results": data, "count": len(data)}
except Exception as e:
logger.error(f"Error listing ETF snapshots: {e}")
raise HTTPException(status_code=500, detail="Failed to list ETF snapshots")
@router.get("/etf/snapshot/{snapshot_id}")
async def get_etf_snapshot(snapshot_id: str, db: AsyncSession = Depends(get_db)):
try:
from uuid import UUID
sid = UUID(snapshot_id)
sres = await db.execute(select(ETFHoldingsSnapshot).where(ETFHoldingsSnapshot.id == sid))
snap = sres.scalar_one_or_none()
if not snap:
raise HTTPException(status_code=404, detail="Snapshot not found")
hres = await db.execute(select(ETFHolding).where(ETFHolding.snapshot_id == sid))
holdings = [
{
"name": h.name,
"cusip": h.cusip,
"ticker": h.ticker,
"shares": h.shares,
"value": h.value,
"percentage": h.percentage,
}
for h in hres.scalars().all()
]
return {
"snapshot": {
"id": str(snap.id),
"ticker": snap.ticker,
"snapshot_date": snap.snapshot_date.isoformat() if snap.snapshot_date else None,
"source": snap.source,
"cik": snap.cik,
"filing_accession": snap.filing_accession,
"xml_url": snap.xml_url,
},
"holdings": holdings,
"holdings_count": len(holdings),
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error fetching ETF snapshot {snapshot_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to fetch ETF snapshot")
# ==============================
# Financial records browsing list
# ==============================
@router.get("/financial/records")
async def list_financial_records(
ticker: str | None = None,
period_type: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
limit: int = 100,
offset: int = 0,
db: AsyncSession = Depends(get_db),
):
try:
from sqlalchemy import and_, desc
q = select(FinancialData)
conditions = []
if ticker:
conditions.append(FinancialData.ticker == ticker.upper())
if period_type and period_type.lower() in ("quarterly", "annual"):
conditions.append(FinancialData.period_type == period_type.lower())
from datetime import datetime, timezone
if start_date:
try:
sd = datetime.fromisoformat(start_date)
if sd.tzinfo is None:
sd = sd.replace(tzinfo=timezone.utc)
conditions.append(FinancialData.period_date >= sd)
except Exception:
pass
if end_date:
try:
ed = datetime.fromisoformat(end_date)
if ed.tzinfo is None:
ed = ed.replace(tzinfo=timezone.utc)
conditions.append(FinancialData.period_date <= ed)
except Exception:
pass
if conditions:
from sqlalchemy import and_ as _and
q = q.where(_and(*conditions))
q = q.order_by(desc(FinancialData.period_date)).limit(limit).offset(offset)
res = await db.execute(q)
records = res.scalars().all()
data = []
for r in records:
data.append({
"ticker": r.ticker,
"period_date": r.period_date.isoformat() if r.period_date else None,
"period_type": r.period_type,
"filing_type": r.filing_type,
"revenue": r.revenue,
"net_income": r.net_income,
"total_assets": r.total_assets,
"total_equity": r.total_equity,
"shares_outstanding": r.shares_outstanding,
"data_source": r.data_source,
"is_estimated": r.is_estimated,
})
return {"results": data, "count": len(data)}
except Exception as e:
logger.error(f"Error listing financial records: {e}")
raise HTTPException(status_code=500, detail="Failed to list financial records")

@ -0,0 +1,424 @@
"""
Error log API endpoints
"""
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import case, select, desc, and_, or_, func
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.models.error_log import ErrorLog
from app.schemas.error_log import (
ErrorLogResponse,
ErrorLogListResponse,
ErrorLogStats,
ErrorLogUpdate
)
router = APIRouter()
ALLOWED_SORT_FIELDS = {"created_at", "status_code", "response_time_ms"}
@router.get(
"/logs",
response_model=ErrorLogListResponse,
summary="Get error logs",
description="""
Retrieve error logs with filtering and pagination options.
**Filters:**
- Date range (start_date, end_date)
- Error type
- Status code range
- Endpoint pattern
- Resolution status
**Sorting:**
- By date (newest first by default)
- By status code
- By response time
**Pagination:**
- Configurable page size (default: 50, max: 200)
- Page-based navigation
"""
)
async def get_error_logs(
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=200, description="Items per page"),
start_date: Optional[datetime] = Query(None, description="Filter by start date"),
end_date: Optional[datetime] = Query(None, description="Filter by end date"),
error_type: Optional[str] = Query(None, description="Filter by error type"),
status_code: Optional[int] = Query(None, description="Filter by status code"),
endpoint: Optional[str] = Query(None, description="Filter by endpoint (supports wildcards)"),
is_resolved: Optional[bool] = Query(None, description="Filter by resolution status"),
sort_by: str = Query("created_at", description="Sort field: created_at, status_code, response_time_ms"),
sort_order: str = Query("desc", description="Sort order: asc or desc"),
db: AsyncSession = Depends(get_db)
):
"""Get paginated error logs with filters"""
# Build query
query = select(ErrorLog)
# Apply filters
filters = []
if start_date:
filters.append(ErrorLog.created_at >= start_date)
if end_date:
filters.append(ErrorLog.created_at <= end_date)
if error_type:
filters.append(ErrorLog.error_type == error_type)
if status_code:
filters.append(ErrorLog.status_code == status_code)
if endpoint:
# Support wildcard matching
if '*' in endpoint:
pattern = endpoint.replace('*', '%')
filters.append(ErrorLog.endpoint.like(pattern))
else:
filters.append(ErrorLog.endpoint == endpoint)
if is_resolved is not None:
filters.append(ErrorLog.is_resolved == is_resolved)
if filters:
query = query.where(and_(*filters))
# Apply sorting (validate sort_by against allowed fields)
if sort_by not in ALLOWED_SORT_FIELDS:
sort_by = "created_at"
sort_column = getattr(ErrorLog, sort_by, ErrorLog.created_at)
if sort_order.lower() == "desc":
query = query.order_by(desc(sort_column))
else:
query = query.order_by(sort_column)
# Get total count
count_query = select(func.count()).select_from(ErrorLog)
if filters:
count_query = count_query.where(and_(*filters))
result = await db.execute(count_query)
total_count = result.scalar()
# Apply pagination
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
# Execute query
result = await db.execute(query)
error_logs = result.scalars().all()
# Calculate pagination info
total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 0
return ErrorLogListResponse(
items=[log.to_dict() for log in error_logs],
total=total_count,
page=page,
page_size=page_size,
total_pages=total_pages
)
@router.get(
"/logs/{log_id}",
response_model=ErrorLogResponse,
summary="Get error log by ID",
description="Retrieve detailed information about a specific error log"
)
async def get_error_log(
log_id: int,
db: AsyncSession = Depends(get_db)
):
"""Get specific error log by ID"""
result = await db.execute(
select(ErrorLog).where(ErrorLog.id == log_id)
)
error_log = result.scalar_one_or_none()
if not error_log:
raise HTTPException(
status_code=404,
detail=f"Error log with ID {log_id} not found"
)
return ErrorLogResponse(**error_log.to_dict())
@router.get(
"/by-request/{request_id}",
response_model=ErrorLogResponse,
summary="Get error log by request ID",
description="Retrieve error log information for a specific request ID"
)
async def get_error_by_request_id(
request_id: str,
db: AsyncSession = Depends(get_db)
):
"""Get error log by request ID"""
result = await db.execute(
select(ErrorLog).where(ErrorLog.request_id == request_id).order_by(desc(ErrorLog.created_at))
)
error_log = result.scalar_one_or_none()
if not error_log:
# Return 404 but it's ok if no error exists for this request
raise HTTPException(
status_code=404,
detail=f"No error log found for request ID {request_id}"
)
return ErrorLogResponse(**error_log.to_dict())
@router.get(
"/stats",
response_model=ErrorLogStats,
summary="Get error statistics",
description="""
Get aggregated statistics about errors.
**Statistics include:**
- Total error count
- Errors by type
- Errors by status code
- Errors by endpoint
- Time-based trends
- Resolution rate
"""
)
async def get_error_stats(
start_date: Optional[datetime] = Query(None, description="Start date for statistics"),
end_date: Optional[datetime] = Query(None, description="End date for statistics"),
db: AsyncSession = Depends(get_db)
):
"""Get error statistics"""
# Default to last 7 days if no dates provided
if not end_date:
end_date = datetime.now(timezone.utc)
if not start_date:
start_date = end_date - timedelta(days=7)
# Build base filter
date_filter = and_(
ErrorLog.created_at >= start_date,
ErrorLog.created_at <= end_date
)
# Get total, resolved count, and avg response time in a single query
summary_result = await db.execute(
select(
func.count().label('total'),
func.count(case((ErrorLog.is_resolved == True, 1))).label('resolved'),
func.avg(case((ErrorLog.response_time_ms.isnot(None), ErrorLog.response_time_ms))).label('avg_time'),
).select_from(ErrorLog).where(date_filter)
)
summary_row = summary_result.one()
total_errors = summary_row.total
resolved_errors = summary_row.resolved
avg_response_time = summary_row.avg_time or 0
# Get errors by type
type_result = await db.execute(
select(
ErrorLog.error_type,
func.count().label('count')
).where(date_filter)
.group_by(ErrorLog.error_type)
.order_by(desc('count'))
.limit(10)
)
errors_by_type = {row.error_type: row.count for row in type_result}
# Get errors by status code
status_result = await db.execute(
select(
ErrorLog.status_code,
func.count().label('count')
).where(date_filter)
.group_by(ErrorLog.status_code)
.order_by(desc('count'))
.limit(10)
)
errors_by_status = {str(row.status_code): row.count for row in status_result}
# Get errors by endpoint (top 10)
endpoint_result = await db.execute(
select(
ErrorLog.endpoint,
func.count().label('count')
).where(date_filter)
.group_by(ErrorLog.endpoint)
.order_by(desc('count'))
.limit(10)
)
errors_by_endpoint = {row.endpoint: row.count for row in endpoint_result}
# Get hourly trend for last 24 hours if within range
hourly_trend = {}
if (end_date - start_date).days <= 1:
# Dialect-aware date formatting
dialect_name = db.bind.dialect.name if db.bind else "sqlite"
if dialect_name == "postgresql":
hour_expr = func.to_char(ErrorLog.created_at, 'YYYY-MM-DD HH24:00').label('hour')
else:
hour_expr = func.strftime('%Y-%m-%d %H:00', ErrorLog.created_at).label('hour')
hourly_result = await db.execute(
select(
hour_expr,
func.count().label('count')
).where(date_filter)
.group_by('hour')
.order_by('hour')
)
hourly_trend = {row.hour: row.count for row in hourly_result}
return ErrorLogStats(
total_errors=total_errors,
resolved_errors=resolved_errors,
unresolved_errors=total_errors - resolved_errors,
resolution_rate=(resolved_errors / total_errors * 100) if total_errors > 0 else 0,
errors_by_type=errors_by_type,
errors_by_status_code=errors_by_status,
errors_by_endpoint=errors_by_endpoint,
average_response_time_ms=avg_response_time,
hourly_trend=hourly_trend,
start_date=start_date.isoformat(),
end_date=end_date.isoformat()
)
@router.patch(
"/logs/{log_id}",
response_model=ErrorLogResponse,
summary="Update error log",
description="Update error log resolution status and notes"
)
async def update_error_log(
log_id: int,
update_data: ErrorLogUpdate,
db: AsyncSession = Depends(get_db)
):
"""Update error log (mark as resolved, add notes, etc.)"""
result = await db.execute(
select(ErrorLog).where(ErrorLog.id == log_id)
)
error_log = result.scalar_one_or_none()
if not error_log:
raise HTTPException(
status_code=404,
detail=f"Error log with ID {log_id} not found"
)
# Update fields
if update_data.is_resolved is not None:
error_log.is_resolved = update_data.is_resolved
if update_data.is_resolved:
error_log.resolved_at = datetime.now(timezone.utc)
else:
error_log.resolved_at = None
if update_data.resolution_notes is not None:
error_log.resolution_notes = update_data.resolution_notes
await db.commit()
await db.refresh(error_log)
return ErrorLogResponse(**error_log.to_dict())
@router.delete(
"/logs/old",
summary="Delete old error logs",
description="Delete error logs older than specified days"
)
async def delete_old_logs(
days_old: int = Query(30, ge=1, le=365, description="Delete logs older than this many days"),
only_resolved: bool = Query(True, description="Only delete resolved errors"),
db: AsyncSession = Depends(get_db)
):
"""Delete old error logs"""
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_old)
# Build delete query
filters = [ErrorLog.created_at < cutoff_date]
if only_resolved:
filters.append(ErrorLog.is_resolved == True)
# Get count of logs to delete
count_result = await db.execute(
select(func.count()).select_from(ErrorLog).where(and_(*filters))
)
count = count_result.scalar()
# Delete logs
await db.execute(
ErrorLog.__table__.delete().where(and_(*filters))
)
await db.commit()
return {
"message": f"Deleted {count} error logs older than {days_old} days",
"deleted_count": count,
"cutoff_date": cutoff_date.isoformat()
}
@router.delete(
"/logs",
summary="Delete all error logs",
description="Delete all error logs (use with caution)"
)
async def delete_all_error_logs(
confirm: bool = Query(False, description="Must be true to confirm deletion"),
only_resolved: bool = Query(False, description="Only delete resolved errors"),
db: AsyncSession = Depends(get_db)
):
"""Delete all error logs"""
if not confirm:
raise HTTPException(
status_code=400,
detail="Must set confirm=true to delete all logs"
)
# Build delete query
filters = []
if only_resolved:
filters.append(ErrorLog.is_resolved == True)
# Get count of logs to delete
if filters:
count_result = await db.execute(
select(func.count()).select_from(ErrorLog).where(and_(*filters))
)
# Delete with filters
await db.execute(
ErrorLog.__table__.delete().where(and_(*filters))
)
else:
count_result = await db.execute(
select(func.count()).select_from(ErrorLog)
)
# Delete all logs
await db.execute(ErrorLog.__table__.delete())
count = count_result.scalar()
await db.commit()
return {
"message": f"Deleted all {count} error logs" + (" (resolved only)" if only_resolved else ""),
"deleted_count": count
}

@ -0,0 +1,95 @@
"""
ETF endpoints (clean and correctly indented)
"""
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Depends
import asyncio
import logging
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.services.etf_loader_service import etf_loader_service
from app.services.etf_holdings_fetcher import etf_holdings_fetcher
from app.models.etf import CusipMap, ETFCIKMap, ETFSeriesMap
router = APIRouter()
logger = logging.getLogger("app.api.v1.etf")
class ETFHoldingsOut(BaseModel):
success: bool
ticker: Optional[str] = None
as_of_date: Optional[str] = None
cik: Optional[str] = None
holdings_count: Optional[int] = None
holdings: Optional[list] = None
availability: Optional[dict] = None
error: Optional[str] = None
@router.get("/holdings/{ticker}", response_model=ETFHoldingsOut)
async def get_etf_holdings(
ticker: str,
as_of_date: Optional[str] = Query(None, description="YYYY-MM-DD"),
top_n: Optional[int] = Query(None, description="Return top N holdings by weight/value (mutually exclusive with top_percentage)"),
top_percentage: Optional[float] = Query(None, description="Return minimal set covering X percent (e.g., 0.5 or 50 for 50%). Mutually exclusive with top_n"),
db: AsyncSession = Depends(get_db),
):
target_dt: Optional[datetime] = None
if as_of_date:
try:
target_dt = datetime.strptime(as_of_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
logger.info(
f"get_etf_holdings start ticker={ticker} as_of_date={as_of_date} top_n={top_n} top_percentage={top_percentage}"
)
try:
if top_n is not None and (top_n <= 0):
raise HTTPException(status_code=400, detail="top_n must be > 0")
if top_percentage is not None and (top_percentage <= 0):
raise HTTPException(status_code=400, detail="top_percentage must be > 0")
result = await asyncio.wait_for(
etf_holdings_fetcher.get_holdings(
db,
ticker,
target_dt,
top_n=top_n,
top_percentage=top_percentage,
),
timeout=55.0,
)
logger.info(
f"get_etf_holdings done ticker={ticker} count={result.get('holdings_count')} "
f"success={result.get('success')}"
)
except asyncio.TimeoutError:
logger.warning(f"get_etf_holdings timeout ticker={ticker}")
raise HTTPException(status_code=504, detail="ETF holdings request timed out. Please retry.")
if not result.get("success"):
availability = result.get("availability")
if availability is not None:
return ETFHoldingsOut(**result)
raise HTTPException(status_code=404, detail=result.get("error", "ETF holdings not found"))
return ETFHoldingsOut(**result)
class RefreshMapsOut(BaseModel):
cusip_rows: int = Field(...)
etf_rows: int = Field(...)
@router.post("/admin/refresh-maps", response_model=RefreshMapsOut)
async def refresh_etf_maps(db: AsyncSession = Depends(get_db)):
refreshed = await etf_loader_service.refresh_all(db)
return RefreshMapsOut(**refreshed)
# All other admin endpoints (manual upserts/deletes) have been removed per request.

@ -0,0 +1,662 @@
"""
Financial data endpoints
"""
from datetime import datetime, timezone, date
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db, AsyncSessionLocal
from app.schemas.financial import (
FinancialDataRequest,
FinancialDataResponse,
BulkFinancialDataRequest,
BulkFinancialDataResponse,
BulkFinancialDataItem,
ErrorResponse,
ErrorType,
CompanyInfo,
FinancialDataPoint,
CalculatedMetricsData
)
from app.services.sec_data_service import SECDataService
from app.core.config import settings
from app.utils.date_utils import quarters_to_date_range
from app.utils.cache import (
build_cache_key,
get_cached_response,
set_cached_response,
)
router = APIRouter()
@router.post(
"/data",
response_model=FinancialDataResponse,
responses={
400: {"model": ErrorResponse, "description": "Invalid request parameters"},
404: {"model": ErrorResponse, "description": "Data not found"},
500: {"model": ErrorResponse, "description": "Internal server error"}
},
summary="Get SEC EDGAR financial data for a ticker",
description="""
Retrieve comprehensive financial data directly from SEC EDGAR filings for a specific ticker and time period.
**🔥 Three Ways to Specify Time Period (choose one):**
1. **Period String** (NEW! Most convenient):
- `period`: "1d", "7d", "30d", "1m", "3m", "6m", "1y", "2y", "5y", "max"
- Examples: `{"ticker": "AAPL", "period": "1y"}` - Last 1 year of data
- Example: `{"ticker": "TSLA", "period": "max"}` - All available data from listing date to SEC limits
2. **Date Range** (Traditional):
- `start_date` + `end_date`: Specific date range
- Example: `{"ticker": "AAPL", "start_date": "2024-01-01", "end_date": "2024-12-31"}`
3. **Quarters** (Quarter-based):
- `quarters`: List of quarters like ["2024Q1", "2024Q2"]
- Example: `{"ticker": "AAPL", "quarters": ["2024Q1", "2024Q2", "2024Q3"]}`
**Data Sources:**
- **Financial Data**: Direct SEC EDGAR API calls (revenue, income, assets, cash flow)
- **Price Data**: Available via separate price data endpoints using yfinance-plus
**This endpoint returns:**
- Company information (name, CIK, sector, industry)
- Financial statements data from SEC filings (income statement, balance sheet, cash flow)
- Calculated financial metrics (ratios, margins, growth rates)
- Period types: quarterly (10-Q) and annual (10-K) filings
**Performance Features:**
- Database caching to avoid repeated SEC API calls
- Historical data available from 1994-present
- 15+ years of data typically available for most companies
- Use `force_refresh=true` to fetch fresh data from SEC EDGAR
**Data Quality:**
- All financial data sourced directly from official SEC filings
- No estimated or synthetic data - only actual reported figures
- Automatic validation and error handling for missing periods
**Example Requests:**
```json
// Using period (simplest)
{
"ticker": "AAPL",
"period": "1y",
"include_metrics": true
}
// Using date range
{
"ticker": "MSFT",
"start_date": "2024-01-01",
"end_date": "2024-12-31",
"period_type": "quarterly"
}
// Using quarters
{
"ticker": "GOOGL",
"quarters": ["2024Q1", "2024Q2"],
"include_metrics": true
}
```
"""
)
async def get_financial_data(
request: FinancialDataRequest,
response: Response,
db: AsyncSession = Depends(get_db)
):
"""Get financial data for a ticker using period, quarters, or date range"""
try:
# Use the updated service that handles period resolution
from app.services.financial_service import FinancialService
financial_service = FinancialService()
# Resolve time parameters for metadata
from app.utils.date_utils import resolve_time_parameters
resolved_start, resolved_end = resolve_time_parameters(
request.start_date, request.end_date, request.quarters, request.period, request.ticker,
ticker_max_range_fn=financial_service._get_ticker_max_range
)
# Build cache key using normalized inputs
cache_key = build_cache_key(
"financial:data",
request.ticker.upper(),
request.period_type.value if hasattr(request.period_type, 'value') else str(request.period_type),
"metrics" if request.include_metrics else "no-metrics",
(resolved_start.date().isoformat() if resolved_start else ""),
(resolved_end.date().isoformat() if resolved_end else ""),
)
# Try cache unless force_refresh
if not request.force_refresh:
cached = await get_cached_response(cache_key)
if cached:
cached_body, etag = cached
response.headers["X-Cache"] = "HIT"
response.headers["Cache-Control"] = f"public, max-age={settings.CACHE_TTL}"
response.headers["ETag"] = etag
return cached_body
data = await financial_service.get_or_create_company_data(
db,
request.ticker,
start_date=request.start_date,
end_date=request.end_date,
quarters=request.quarters,
period=request.period,
force_refresh=request.force_refresh
)
# Format response
company = data["company"]
financial_data = data["financial_data"]
calculated_metrics = data["calculated_metrics"]
# Convert to response models
company_info = CompanyInfo(
ticker=company.ticker,
name=company.name,
cik=company.cik,
sector=company.sector,
industry=company.industry,
business_description=company.business_description
)
# Filter financial data by period type
if request.period_type != "all":
financial_data = [fd for fd in financial_data if fd.period_type == request.period_type]
# Merge financial data with calculated metrics
financial_points = []
for fd in financial_data:
# Convert to dict for merging
fd_dict = fd.__dict__ if hasattr(fd, '__dict__') else {}
# Find matching calculated metrics for this period
matching_metrics = None
if request.include_metrics and calculated_metrics:
for cm in calculated_metrics:
if cm.period_date == fd.period_date:
matching_metrics = cm
break
# Merge metrics into financial data point
if matching_metrics:
fd_dict.update({
'pe_ratio': matching_metrics.pe_ratio,
'pb_ratio': matching_metrics.pb_ratio,
'ps_ratio': matching_metrics.ps_ratio,
'roe': matching_metrics.roe,
'roa': matching_metrics.roa,
'gross_margin': matching_metrics.gross_margin,
'operating_margin': matching_metrics.operating_margin,
'net_margin': matching_metrics.net_margin,
'debt_to_equity': matching_metrics.debt_to_equity,
'debt_to_assets': matching_metrics.debt_to_assets,
'ocf_margin': matching_metrics.ocf_margin,
'fcf_margin': matching_metrics.fcf_margin,
'market_cap': matching_metrics.market_cap
})
financial_points.append(FinancialDataPoint.model_validate(fd_dict))
# Calculate actual date range from returned data
actual_start_date = resolved_start
actual_end_date = resolved_end
if financial_points:
# Get actual start and end dates from the financial data
actual_start_date = min(point.period_date for point in financial_points)
actual_end_date = max(point.period_date for point in financial_points)
body = FinancialDataResponse(
company=company_info,
financial_data=financial_points,
metadata={
"request_id": str(request.ticker),
"data_points": len(financial_points),
"period_type": request.period_type.value,
"quarters_requested": request.quarters if request.quarters else None,
"date_range": {
"start": actual_start_date.isoformat(),
"end": actual_end_date.isoformat()
},
"last_updated": datetime.now(timezone.utc).isoformat()
}
)
# Cache the response
etag = await set_cached_response(cache_key, body.model_dump(), ttl_seconds=settings.CACHE_TTL)
response.headers["X-Cache"] = "MISS"
response.headers["Cache-Control"] = f"public, max-age={settings.CACHE_TTL}"
response.headers["ETag"] = etag
return body
except ValueError as e:
if "No data returned" in str(e):
raise HTTPException(
status_code=404,
detail={
"error_type": ErrorType.DATA_NOT_FOUND,
"message": f"No financial data found for ticker {request.ticker}",
"detail": {
"ticker": request.ticker,
"period": f"{request.start_date} to {request.end_date}"
}
}
)
raise HTTPException(
status_code=400,
detail={
"error_type": ErrorType.PARSING_ERROR,
"message": str(e)
}
)
except Exception as e:
raise HTTPException(
status_code=500,
detail={
"error_type": ErrorType.DATABASE_ERROR,
"message": "An error occurred while processing your request",
"detail": {"error": str(e)}
}
)
@router.get(
"/data/{ticker}",
response_model=FinancialDataResponse,
summary="Get financial data by ticker (simplified)",
description="""
Simplified GET endpoint to retrieve financial data with query parameters.
**Time Period Options:**
- Use `period` for convenience: "1d", "7d", "1m", "3m", "6m", "1y", "2y", "5y", "max"
- OR use `start_date` and `end_date` for specific date range
- Cannot use both approaches simultaneously
**Examples:**
- `/api/v1/financial/data/AAPL?period=1y&include_metrics=true` - Last year of financial data
- `/api/v1/financial/data/AAPL?start_date=2024-01-01&end_date=2024-12-31&period_type=quarterly` - Specific date range
"""
)
async def get_financial_data_simple(
ticker: str,
response: Response,
period: Optional[str] = Query(None, description="Period like '1d', '7d', '1m', '3m', '6m', '1y', '2y', '5y', 'max'"),
start_date: Optional[date] = Query(None, description="Start date for data retrieval (use with end_date, not with period)"),
end_date: Optional[date] = Query(None, description="End date for data retrieval (use with start_date, not with period)"),
period_type: str = Query("all", description="Period type: quarterly, annual, or all"),
include_metrics: bool = Query(True, description="Include calculated metrics"),
force_refresh: bool = Query(False, description="Force refresh from SEC"),
db: AsyncSession = Depends(get_db)
):
"""Simplified GET endpoint for financial data"""
# Validate that either period OR date range is provided, not both
if period and (start_date or end_date):
raise HTTPException(
status_code=400,
detail={
"error_type": ErrorType.VALIDATION_ERROR,
"message": "Cannot specify both period and date range. Use either period OR start_date+end_date."
}
)
if not period and not (start_date and end_date):
raise HTTPException(
status_code=400,
detail={
"error_type": ErrorType.VALIDATION_ERROR,
"message": "Must specify either period OR both start_date and end_date."
}
)
# Create request based on provided parameters
if period:
request = FinancialDataRequest(
ticker=ticker,
period=period,
period_type=period_type,
include_metrics=include_metrics,
force_refresh=force_refresh
)
else:
request = FinancialDataRequest(
ticker=ticker,
start_date=start_date,
end_date=end_date,
period_type=period_type,
include_metrics=include_metrics,
force_refresh=force_refresh
)
return await get_financial_data(request, response, db)
@router.post(
"/data/bulk",
response_model=BulkFinancialDataResponse,
responses={
400: {"model": ErrorResponse, "description": "Invalid request parameters"},
500: {"model": ErrorResponse, "description": "Internal server error"}
},
summary="Get SEC EDGAR financial data for multiple tickers",
description="""
Retrieve comprehensive financial data for multiple tickers in a single request directly from SEC EDGAR filings.
**🔥 Three Ways to Specify Time Period (choose one):**
1. **Period String** (NEW! Most convenient):
- `period`: "1d", "7d", "30d", "1m", "3m", "6m", "1y", "2y", "5y", "max"
- Example: Last 1 year for multiple tickers, or "max" for all available data
2. **Date Range** (Traditional):
- `start_date` + `end_date`: Specific date range
- Example: Specific date range for all tickers
3. **Quarters** (Quarter-based):
- `quarters`: List of quarters like ["2024Q1", "2024Q2"]
- Example: Specific quarters for all tickers
**Data Sources:**
- **Financial Data**: Direct SEC EDGAR API calls (revenue, income, assets, cash flow)
- **Price Data**: Available via separate price data endpoints using yfinance-plus
**Bulk Processing Features:**
- Processes up to 100 tickers in parallel for maximum efficiency
- Returns individual success/failure results for each ticker
- Handles partial failures gracefully (some tickers can fail while others succeed)
- Uses the same robust SEC data retrieval logic as single ticker endpoint
**SEC EDGAR Integration:**
- Direct API calls to official SEC EDGAR database
- All financial data sourced from actual SEC filings (10-K, 10-Q)
- No estimated or synthetic data - only actual reported figures
- Historical data available from 1994-present (15+ years for most companies)
- Automatic validation and error handling for missing periods
**Data Quality & Features:**
- Company information (name, CIK, sector, industry, business description)
- Comprehensive financial statements (income statement, balance sheet, cash flow)
- Calculated financial metrics (ratios, margins, growth rates)
- Period types: quarterly (10-Q) and annual (10-K) filings
- Database caching to avoid repeated SEC API calls
**Performance:**
- Parallel processing for bulk requests
- Intelligent caching and rate limiting
- Use `force_refresh=true` to fetch fresh data from SEC EDGAR
**Example Requests:**
```json
// Using period (simplest)
{
"tickers": ["AAPL", "MSFT", "GOOGL"],
"period": "1y",
"include_metrics": true
}
// Using date range
{
"tickers": ["NVDA", "AMD", "INTC"],
"start_date": "2024-01-01",
"end_date": "2024-12-31",
"period_type": "quarterly"
}
// Using quarters
{
"tickers": ["TSLA", "F", "GM"],
"quarters": ["2024Q1", "2024Q2"],
"include_metrics": true
}
```
Each ticker result includes the same comprehensive financial data structure as the single ticker endpoint.
Failed tickers will have detailed error messages while successful ones will have complete SEC filing data.
"""
)
async def get_bulk_financial_data(
request: BulkFinancialDataRequest,
db: AsyncSession = Depends(get_db)
):
"""Get financial data for multiple tickers"""
# Convert time parameters to dates using the same logic as single endpoint
if request.period:
# Use period approach - import the parse_period function
from app.utils.date_utils import parse_period
try:
start_date, end_date = parse_period(request.period)
except ValueError as e:
raise HTTPException(
status_code=400,
detail={
"error_type": ErrorType.VALIDATION_ERROR,
"message": f"Invalid period format: {str(e)}"
}
)
elif request.quarters:
try:
start_date, end_date = quarters_to_date_range(request.quarters)
except ValueError as e:
raise HTTPException(
status_code=400,
detail={
"error_type": ErrorType.VALIDATION_ERROR,
"message": str(e)
}
)
else:
# Convert date to datetime for internal processing
start_date = datetime.combine(request.start_date, datetime.min.time()).replace(tzinfo=timezone.utc) if request.start_date else None
end_date = datetime.combine(request.end_date, datetime.max.time()).replace(tzinfo=timezone.utc) if request.end_date else None
# Validate date range
if start_date and end_date and start_date >= end_date:
raise HTTPException(
status_code=400,
detail={
"error_type": ErrorType.VALIDATION_ERROR,
"message": "Start date must be before end date"
}
)
# Check if requested period is valid (SEC data available from 1994)
if start_date and start_date.year < settings.SEC_DATA_START_YEAR:
raise HTTPException(
status_code=400,
detail={
"error_type": ErrorType.INVALID_PERIOD,
"message": f"SEC data is only available from {settings.SEC_DATA_START_YEAR}",
"detail": {
"requested_start": start_date.isoformat(),
"earliest_available": f"{settings.SEC_DATA_START_YEAR}-01-01"
}
}
)
# Future date check
current_time = datetime.now(timezone.utc)
if start_date and start_date > current_time:
raise HTTPException(
status_code=400,
detail={
"error_type": ErrorType.INVALID_PERIOD,
"message": "Cannot request data for future dates"
}
)
import asyncio
results = []
successful_count = 0
failed_count = 0
async def process_ticker(ticker: str):
"""Process a single ticker and return result"""
try:
# Use an isolated DB session per task to avoid concurrent use of a single session
async with AsyncSessionLocal() as session:
# Get data using existing service
sec_service = SECDataService()
data = await sec_service.get_or_update_company_data(
session,
ticker,
start_date,
end_date,
request.force_refresh
)
# Format response
company = data["company"]
financial_data = data["financial_data"]
calculated_metrics = data["calculated_metrics"] # Always include metrics
# Convert to response models
company_info = CompanyInfo(
ticker=company.ticker,
name=company.name,
cik=company.cik,
sector=company.sector,
industry=company.industry,
business_description=company.business_description
)
# Filter financial data by period type
if request.period_type != "all":
financial_data = [fd for fd in financial_data if fd.period_type == request.period_type]
# Merge financial data with calculated metrics
financial_points = []
for fd in financial_data:
# Convert to dict for merging
fd_dict = fd.__dict__ if hasattr(fd, '__dict__') else {}
# Find matching calculated metrics for this period
matching_metrics = None
if request.include_metrics and calculated_metrics:
for cm in calculated_metrics:
if cm.period_date == fd.period_date:
matching_metrics = cm
break
# Merge metrics into financial data point
if matching_metrics:
fd_dict.update({
'pe_ratio': matching_metrics.pe_ratio,
'pb_ratio': matching_metrics.pb_ratio,
'ps_ratio': matching_metrics.ps_ratio,
'roe': matching_metrics.roe,
'roa': matching_metrics.roa,
'gross_margin': matching_metrics.gross_margin,
'operating_margin': matching_metrics.operating_margin,
'net_margin': matching_metrics.net_margin,
'debt_to_equity': matching_metrics.debt_to_equity,
'debt_to_assets': matching_metrics.debt_to_assets,
'ocf_margin': matching_metrics.ocf_margin,
'fcf_margin': matching_metrics.fcf_margin,
'market_cap': matching_metrics.market_cap
})
financial_points.append(FinancialDataPoint.model_validate(fd_dict))
# Calculate actual date range from returned data
actual_start_date = start_date
actual_end_date = end_date
if financial_points:
# Get actual start and end dates from the financial data
actual_start_date = min(point.period_date for point in financial_points)
actual_end_date = max(point.period_date for point in financial_points)
response = FinancialDataResponse(
company=company_info,
financial_data=financial_points,
metadata={
"request_id": str(ticker),
"data_points": len(financial_points),
"period_type": request.period_type.value,
"quarters_requested": request.quarters if request.quarters else None,
"date_range": {
"start": actual_start_date.isoformat(),
"end": actual_end_date.isoformat()
},
"last_updated": datetime.now(timezone.utc).isoformat()
}
)
return BulkFinancialDataItem(
ticker=ticker,
success=True,
data=response,
error=None
)
except Exception as e:
# Handle individual ticker failure
error_message = str(e)
if "No data returned" in error_message:
error_message = f"No financial data found for ticker {ticker}"
elif "Invalid ticker" in error_message:
error_message = f"Invalid or unknown ticker: {ticker}"
return BulkFinancialDataItem(
ticker=ticker,
success=False,
data=None,
error=error_message
)
# Process all tickers in parallel with concurrency limit
semaphore = asyncio.Semaphore(10) # Limit concurrent operations to avoid overwhelming DB/APIs
async def process_with_limit(ticker: str):
async with semaphore:
return await process_ticker(ticker)
# Execute all tickers in parallel
tasks = [process_with_limit(ticker) for ticker in request.tickers]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Count successful and failed results
successful_count = 0
failed_count = 0
for i, result in enumerate(results):
if isinstance(result, Exception):
# Handle unexpected exceptions
error_message = f"Unexpected error processing {request.tickers[i]}: {str(result)}"
results[i] = BulkFinancialDataItem(
ticker=request.tickers[i],
success=False,
data=None,
error=error_message
)
failed_count += 1
elif result.success:
successful_count += 1
else:
failed_count += 1
return BulkFinancialDataResponse(
results=results,
metadata={
"total_requested": len(request.tickers),
"successful": successful_count,
"failed": failed_count,
"period_type": request.period_type.value,
"quarters_requested": request.quarters if request.quarters else None,
"date_range": {
"start": start_date.isoformat(),
"end": end_date.isoformat()
},
"force_refresh": request.force_refresh,
"processed_at": datetime.now(timezone.utc).isoformat()
}
)

@ -0,0 +1,351 @@
"""
FRED (Federal Reserve Economic Data) endpoints
연방준비제도 경제 데이터 API
"""
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Depends
from sqlalchemy.ext.asyncio import AsyncSession
import logging
from app.core.database import get_db
from app.services.fred_service import fred_service
from app.services.fred_proxy_service import fred_proxy_service
router = APIRouter()
logger = logging.getLogger("app.api.v1.fred")
# Deprecated: Individual endpoints replaced by universal proxy
# Use /proxy/{endpoint} instead for all FRED API access
@router.get("/stats/usage")
async def get_fred_usage_stats(
days: int = Query(7, ge=1, le=30, description="Number of days to include in stats"),
use_proxy_stats: bool = Query(True, description="Use enhanced proxy service statistics"),
db: AsyncSession = Depends(get_db)
):
"""
Get FRED API usage statistics and cache performance
Returns detailed statistics about API usage, cache performance, and daily limits.
Now includes enhanced proxy service statistics.
**Example Response**:
```json
{
"success": true,
"data": {
"daily_limit": 1000,
"used_today": 45,
"remaining_today": 955,
"usage_percentage": 4.5,
"can_make_requests": true,
"daily_stats": [
{
"date": "2025-01-14",
"total_calls": 45,
"successful_calls": 44,
"total_records": 1250,
"success_rate": 97.8
}
],
"endpoint_stats": [
{
"endpoint": "series",
"call_count": 25
}
],
"proxy_info": {
"mode": "pass_through_proxy",
"supported_endpoints": "all_fred_endpoints"
}
}
}
```
**Parameters**:
- `days`: Number of days to include in historical statistics (1-30)
- `use_proxy_stats`: Use enhanced proxy service statistics (recommended)
**Metrics Included**:
- Daily API usage and remaining quota
- Historical usage patterns
- Endpoint-specific usage statistics (NEW!)
- Success rates and error tracking
- Proxy service information (NEW!)
"""
try:
logger.info(f"📊 Getting FRED usage stats for {days} days (proxy_stats={use_proxy_stats})")
if use_proxy_stats:
# 향상된 proxy 서비스 통계 사용
result = await fred_proxy_service.get_api_usage_stats(db, days)
else:
# 기존 서비스 통계 사용
result = await fred_service.get_api_usage_stats(db, days)
if not result.get('success'):
logger.error(f"❌ Failed to get FRED usage stats: {result.get('error')}")
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve usage statistics: {result.get('error')}"
)
stats_data = result['data']
used_today = stats_data['used_today']
remaining = stats_data['remaining_today']
logger.info(f"✅ FRED usage stats: {used_today}/1000 used, {remaining} remaining")
return {
"success": True,
"message": f"FRED API usage: {used_today}/1000 used today ({remaining} remaining)",
"data": stats_data,
"metadata": {
"source": "fred.stlouisfed.org",
"daily_limit": 1000,
"service_type": "proxy_service" if use_proxy_stats else "original_service",
"enhanced_features": use_proxy_stats
}
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error getting FRED usage stats: {e}")
raise HTTPException(
status_code=500,
detail=f"Internal server error while fetching usage statistics: {str(e)}"
)
# Removed: /search endpoint - use /proxy/series/search instead
@router.get("/proxy/{endpoint:path}")
async def fred_proxy_endpoint(
endpoint: str,
db: AsyncSession = Depends(get_db),
series_id: Optional[str] = Query(None, description="Series ID parameter"),
category_id: Optional[int] = Query(None, description="Category ID parameter"),
release_id: Optional[int] = Query(None, description="Release ID parameter"),
source_id: Optional[int] = Query(None, description="Source ID parameter"),
tag_names: Optional[str] = Query(None, description="Tag names parameter"),
realtime_start: Optional[str] = Query(None, description="Realtime start date (YYYY-MM-DD)"),
realtime_end: Optional[str] = Query(None, description="Realtime end date (YYYY-MM-DD)"),
observation_start: Optional[str] = Query(None, description="Observation start date (YYYY-MM-DD)"),
observation_end: Optional[str] = Query(None, description="Observation end date (YYYY-MM-DD)"),
limit: Optional[int] = Query(None, ge=1, le=100000, description="Limit number of results"),
offset: Optional[int] = Query(None, ge=0, description="Offset for pagination"),
order_by: Optional[str] = Query(None, description="Order by parameter"),
sort_order: Optional[str] = Query(None, description="Sort order (asc/desc)"),
search_text: Optional[str] = Query(None, description="Search text"),
search_type: Optional[str] = Query(None, description="Search type"),
frequency: Optional[str] = Query(None, description="Data frequency"),
aggregation_method: Optional[str] = Query(None, description="Aggregation method"),
output_type: Optional[int] = Query(None, description="Output type"),
vintage_dates: Optional[str] = Query(None, description="Vintage dates"),
exclude_tag_names: Optional[str] = Query(None, description="Exclude tag names"),
tag_group_id: Optional[str] = Query(None, description="Tag group ID"),
bypass_limit_check: bool = Query(False, description="Bypass daily limit check (admin only)"),
force_refresh: bool = Query(False, description="Force refresh from API, bypass cache")
):
"""
FRED API Pass-through Proxy
Universal proxy endpoint that forwards requests to any FRED API endpoint while maintaining
our caching and rate limiting logic.
**Supported Endpoints**: All FRED API endpoints are supported
**Examples**:
```bash
# Series information
GET /api/v1/fred/proxy/series?series_id=GDP
# Series observations
GET /api/v1/fred/proxy/series/observations?series_id=UNRATE&limit=12
# Category information
GET /api/v1/fred/proxy/category?category_id=125
# Category children
GET /api/v1/fred/proxy/category/children?category_id=13
# Release information
GET /api/v1/fred/proxy/release?release_id=53
# Search series
GET /api/v1/fred/proxy/series/search?search_text=unemployment&limit=25
# Sources
GET /api/v1/fred/proxy/sources
# Tags
GET /api/v1/fred/proxy/tags?limit=100
```
**Key Features**:
- **Universal Access**: Support for all FRED API endpoints
- **Smart Caching**: 24-hour DB caching for series and observations (NEW!)
- **Permanent Storage**: Historical data permanently stored in database (NEW!)
- **Rate Limiting**: Respects 1,000/day limit with usage tracking
- **Parameter Forwarding**: Automatically forwards all supported parameters
- **Error Handling**: Comprehensive error handling and logging
- **Usage Statistics**: Tracks endpoint usage and performance
**Parameters**:
All standard FRED API parameters are supported including:
- `series_id`, `category_id`, `release_id`, `source_id`
- `realtime_start`, `realtime_end`, `observation_start`, `observation_end`
- `limit`, `offset`, `order_by`, `sort_order`
- `search_text`, `search_type`, `frequency`, `aggregation_method`
- `force_refresh`: Bypass cache and fetch fresh data from FRED API
- `bypass_limit_check`: Skip daily limit validation (admin only)
- And many more...
**Caching Strategy**:
- **Cache Hit**: Returns instantly from database (no API call)
- **Cache Miss**: Fetches from FRED API and stores for 24 hours
- **Permanent Storage**: Historical observations stored permanently
- **API Limit Reached**: Returns cached data even if expired
**Response Format**: Returns original FRED API response with additional metadata
"""
try:
logger.info(f"🔄 FRED proxy request: {endpoint}")
# 파라미터 수집 - None이 아닌 값만 포함
params = {}
# 기본 파라미터들
param_mapping = {
'series_id': series_id,
'category_id': category_id,
'release_id': release_id,
'source_id': source_id,
'tag_names': tag_names,
'realtime_start': realtime_start,
'realtime_end': realtime_end,
'observation_start': observation_start,
'observation_end': observation_end,
'limit': limit,
'offset': offset,
'order_by': order_by,
'sort_order': sort_order,
'search_text': search_text,
'search_type': search_type,
'frequency': frequency,
'aggregation_method': aggregation_method,
'output_type': output_type,
'vintage_dates': vintage_dates,
'exclude_tag_names': exclude_tag_names,
'tag_group_id': tag_group_id
}
# None이 아닌 파라미터만 추가
for key, value in param_mapping.items():
if value is not None:
params[key] = value
# Proxy 서비스 호출
result = await fred_proxy_service.proxy_fred_request(
db, endpoint, params, bypass_limit_check, force_refresh
)
if not result.get('success'):
error_detail = result.get('error', 'Unknown error')
error_details = result.get('details', {})
logger.warning(f"❌ FRED proxy failed: {endpoint} -> {error_detail}")
# 사용량 한도 초과인 경우 429 상태 코드
if 'limit' in error_detail.lower():
raise HTTPException(
status_code=429,
detail=f"FRED API daily limit reached: {error_detail}",
headers={"Retry-After": "86400"} # 24 hours
)
else:
raise HTTPException(
status_code=500,
detail=f"FRED API error: {error_detail}"
)
# 성공 응답
response_data = result['data']
metadata = result['metadata']
# 응답 크기 계산
response_size = metadata.get('response_size', 0)
logger.info(f"✅ FRED proxy success: {endpoint} -> {response_size} records")
return {
"success": True,
"message": f"FRED API proxy: {endpoint} -> {response_size} records",
"data": response_data,
"metadata": {
**metadata,
"endpoint_accessed": endpoint,
"parameters_used": params,
"daily_api_limit": 1000
}
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error in FRED proxy endpoint: {e}")
raise HTTPException(
status_code=500,
detail=f"Internal server error in FRED proxy: {str(e)}"
)
@router.get("/endpoints")
async def get_supported_fred_endpoints():
"""
Get list of supported FRED API endpoints
Returns comprehensive list of all FRED API endpoints that can be accessed
through the proxy service.
**Usage**: Use this to discover available endpoints and their categories.
**Example Response**:
```json
{
"series_endpoints": [
"series",
"series/observations",
"series/search",
"..."
],
"category_endpoints": ["..."],
"release_endpoints": ["..."]
}
```
"""
try:
endpoints = fred_proxy_service.get_supported_endpoints()
return {
"success": True,
"message": "FRED API supported endpoints",
"data": endpoints,
"metadata": {
"total_endpoint_categories": len([k for k in endpoints.keys() if k.endswith('_endpoints')]),
"proxy_mode": "pass_through",
"base_url": "https://api.stlouisfed.org/fred"
}
}
except Exception as e:
logger.error(f"❌ Error getting FRED endpoints: {e}")
raise HTTPException(
status_code=500,
detail=f"Internal server error: {str(e)}"
)

@ -0,0 +1,60 @@
"""
Health check endpoint
"""
from datetime import datetime
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
import redis.asyncio as redis
from app.core.database import get_db
from app.core.config import settings
from app.schemas.financial import HealthCheckResponse
router = APIRouter()
@router.get(
"/health",
response_model=HealthCheckResponse,
summary="Health check",
description="Check the health status of the API and its dependencies"
)
async def health_check(db: AsyncSession = Depends(get_db)):
"""Health check endpoint"""
# Check database
db_status = "unhealthy"
try:
result = await db.execute(text("SELECT 1"))
if result.scalar():
db_status = "healthy"
except Exception:
pass
# Check Redis cache
cache_status = "unhealthy"
try:
r = redis.from_url(settings.REDIS_URL)
await r.ping()
cache_status = "healthy"
await r.close()
except Exception:
pass
# Check SEC data availability
sec_available = True # Simplified for now
# Overall status
overall_status = "healthy"
if db_status != "healthy" or cache_status != "healthy":
overall_status = "degraded"
return HealthCheckResponse(
status=overall_status,
version=settings.APP_VERSION,
database=db_status,
cache=cache_status,
sec_data_available=sec_available,
timestamp=datetime.utcnow()
)

@ -0,0 +1,207 @@
"""
Data migration endpoints
"""
from datetime import datetime
import time
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
import httpx
from app.core.database import get_db
from app.core.config import settings
from app.schemas.financial import MigrationRequest, MigrationResponse, ErrorType
from app.models.financial import FinancialData, CalculatedMetrics, Company
router = APIRouter()
async def verify_migration_key(x_api_key: Optional[str] = Header(None)):
"""Verify migration API key"""
if not settings.ALLOW_MIGRATION:
raise HTTPException(
status_code=403,
detail={
"error_type": ErrorType.AUTHENTICATION_ERROR,
"message": "Migration endpoint is disabled"
}
)
if x_api_key != settings.MIGRATION_API_KEY:
raise HTTPException(
status_code=401,
detail={
"error_type": ErrorType.AUTHENTICATION_ERROR,
"message": "Invalid migration API key"
}
)
@router.post(
"/migrate",
response_model=MigrationResponse,
summary="Migrate data from another instance",
description="""
Migrate financial data from another SEC Investment API instance.
This endpoint allows you to:
- Transfer all data from one instance to another
- Migrate specific tickers only
- Migrate data within specific date ranges
Requires valid migration API key in X-API-Key header.
"""
)
async def migrate_data(
request: MigrationRequest,
db: AsyncSession = Depends(get_db),
_: None = Depends(verify_migration_key)
):
"""Migrate data from another instance"""
start_time = time.time()
total_records = 0
migrated_records = 0
failed_records = 0
errors = []
async with httpx.AsyncClient(timeout=30.0) as client:
try:
# Set up headers for source API
headers = {"X-API-Key": request.api_key}
# Get list of tickers to migrate
if request.tickers:
tickers = request.tickers
else:
# Get all tickers from source
response = await client.get(
f"{request.source_url}/api/v1/companies",
headers=headers
)
if response.status_code == 200:
companies = response.json()
tickers = [c["ticker"] for c in companies]
else:
raise ValueError("Failed to fetch company list from source")
# Migrate each ticker
for ticker in tickers:
try:
# Build query parameters
params = {"ticker": ticker}
if request.start_date:
params["start_date"] = request.start_date.isoformat()
if request.end_date:
params["end_date"] = request.end_date.isoformat()
# Fetch financial data
response = await client.get(
f"{request.source_url}/api/v1/financial/data/{ticker}",
headers=headers,
params=params
)
if response.status_code == 200:
data = response.json()
# Store company info
company_info = data.get("company", {})
if company_info:
company = await db.get(Company, ticker)
if not company:
company = Company(
ticker=ticker,
name=company_info.get("name"),
cik=company_info.get("cik"),
sector=company_info.get("sector"),
industry=company_info.get("industry"),
business_description=company_info.get("business_description")
)
db.add(company)
# Store financial data
for fd in data.get("financial_data", []):
total_records += 1
# Check if exists
existing = await db.get(
FinancialData,
(ticker, fd["period_date"], fd["period_type"])
)
if not existing:
fin_data = FinancialData(**fd, ticker=ticker)
db.add(fin_data)
migrated_records += 1
# Store calculated metrics
for cm in data.get("calculated_metrics", []):
total_records += 1
# Check if exists
existing = await db.get(
CalculatedMetrics,
(ticker, cm["calculation_date"], cm["period_date"])
)
if not existing:
metrics = CalculatedMetrics(**cm, ticker=ticker)
db.add(metrics)
migrated_records += 1
await db.commit()
else:
failed_records += 1
errors.append({
"ticker": ticker,
"error": f"HTTP {response.status_code}: {response.text}"
})
except Exception as e:
failed_records += 1
errors.append({
"ticker": ticker,
"error": str(e)
})
await db.rollback()
duration = time.time() - start_time
return MigrationResponse(
status="completed" if failed_records == 0 else "completed_with_errors",
total_records=total_records,
migrated_records=migrated_records,
failed_records=failed_records,
errors=errors[:10], # Limit errors to first 10
duration_seconds=round(duration, 2)
)
except Exception as e:
return MigrationResponse(
status="failed",
total_records=total_records,
migrated_records=migrated_records,
failed_records=failed_records,
errors=[{"error": str(e)}],
duration_seconds=round(time.time() - start_time, 2)
)
@router.get(
"/migration/export/{ticker}",
summary="Export data for migration",
description="Export financial data for a specific ticker (used by migration process)"
)
async def export_data(
ticker: str,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
db: AsyncSession = Depends(get_db),
_: None = Depends(verify_migration_key)
):
"""Export data for migration"""
# This endpoint would be used by the migration process
# Implementation depends on specific needs
return {
"ticker": ticker,
"message": "Export endpoint for migration",
"note": "This would return raw data for migration purposes"
}

@ -0,0 +1,278 @@
"""
News and Social Media API endpoints for ticker-based sentiment analysis
"""
from datetime import datetime
from typing import Optional, Dict, Any, List
import logging
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from app.services.news_social_service import news_social_service
logger = logging.getLogger(__name__)
router = APIRouter()
class NewsArticleSchema(BaseModel):
"""News article schema for API response"""
title: str
summary: Optional[str] = None
content: Optional[str] = None
url: str
source: str
published_at: Optional[str] = None
author: Optional[str] = None
relevance_score: Optional[float] = None
image_url: Optional[str] = None
tags: List[str] = []
class SocialPostSchema(BaseModel):
"""Social media post schema for API response"""
title: str
content: str
url: str
platform: str
author: str
published_at: Optional[str] = None
score: Optional[int] = None
comments_count: Optional[int] = None
upvotes: Optional[int] = None
downvotes: Optional[int] = None
subreddit: Optional[str] = None
class NewsSourcesSchema(BaseModel):
"""News sources breakdown"""
yahoo_finance: int = 0
newsapi: int = 0
class SocialPlatformsSchema(BaseModel):
"""Social media platforms breakdown"""
reddit: int = 0
class NewsSocialSummarySchema(BaseModel):
"""Summary of news and social data"""
total_items: int
time_range_days: int
oldest_item: Optional[str] = None
newest_item: Optional[str] = None
class NewsSocialResponse(BaseModel):
"""Complete response for ticker news and social data"""
ticker: str
retrieved_at: str
news: Dict[str, Any] = Field(description="News articles and sources breakdown")
social_media: Dict[str, Any] = Field(description="Social media posts and platforms breakdown")
summary: NewsSocialSummarySchema
@router.get("/{ticker}", response_model=NewsSocialResponse)
async def get_ticker_news_and_social(
ticker: str,
days_back: int = Query(7, ge=1, le=30, description="Number of days to look back for articles (1-30)"),
max_articles: int = Query(20, ge=1, le=100, description="Maximum number of news articles to return (1-100)"),
max_social_posts: int = Query(15, ge=0, le=50, description="Maximum number of social media posts to return (0-50)"),
include_social: bool = Query(True, description="Whether to include social media data")
):
"""
Get comprehensive news and social media data for a ticker
- **ticker**: Stock ticker symbol (e.g., AAPL, TSLA, QQQ)
- **days_back**: Number of days to look back for articles (default: 7, max: 30)
- **max_articles**: Maximum number of news articles to return (default: 20, min: 1, max: 100)
- **max_social_posts**: Maximum number of social media posts to return (default: 15, max: 50)
- **include_social**: Whether to include social media data (default: true)
## Data Sources
- **News**: Yahoo Finance, NewsAPI
- **Social Media**: Reddit (multiple investing subreddits)
## Features
- ✅ Parallel data fetching from multiple sources
- ✅ Automatic deduplication and relevance ranking
- ✅ Rate limiting and error handling
- ✅ Comprehensive metadata and source attribution
## Use Cases
- Sentiment analysis and market research
- News aggregation for trading decisions
- Social media monitoring for retail sentiment
- Research and fundamental analysis support
"""
try:
# Validate ticker format
ticker_upper = ticker.upper().strip()
if not ticker_upper or len(ticker_upper) > 10:
raise HTTPException(
status_code=400,
detail=f"Invalid ticker format: {ticker}. Must be 1-10 characters."
)
logger.info(f"Fetching news and social data for {ticker_upper}")
# Get data from service
result = await news_social_service.get_ticker_news_and_social(
ticker=ticker_upper,
days_back=days_back,
max_articles=max_articles,
max_social_posts=max_social_posts,
include_social=include_social
)
logger.info(f"Successfully retrieved {result['news']['total_articles']} articles and {result['social_media']['total_posts']} social posts for {ticker_upper}")
return result
except ValueError as e:
logger.error(f"Invalid input for {ticker}: {e}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error fetching news and social data for {ticker}: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve news and social data for {ticker}. Please try again later."
)
@router.get("/{ticker}/news-only", response_model=Dict[str, Any])
async def get_ticker_news_only(
ticker: str,
days_back: int = Query(7, ge=1, le=30, description="Number of days to look back for articles (1-30)"),
max_articles: int = Query(30, ge=1, le=100, description="Maximum number of news articles to return (1-100)")
):
"""
Get only news articles for a ticker (faster endpoint without social media data)
- **ticker**: Stock ticker symbol (e.g., AAPL, TSLA, QQQ)
- **days_back**: Number of days to look back for articles (default: 7, max: 30)
- **max_articles**: Maximum number of news articles to return (default: 30, min: 1, max: 100)
## Performance
- ⚡ Faster response time (no social media API calls)
- ⚡ Optimized for high-frequency news monitoring
- ⚡ Ideal for news-only sentiment analysis
"""
try:
ticker_upper = ticker.upper().strip()
if not ticker_upper or len(ticker_upper) > 10:
raise HTTPException(
status_code=400,
detail=f"Invalid ticker format: {ticker}. Must be 1-10 characters."
)
logger.info(f"Fetching news-only data for {ticker_upper}")
# Get data with social media disabled
result = await news_social_service.get_ticker_news_and_social(
ticker=ticker_upper,
days_back=days_back,
max_articles=max_articles,
max_social_posts=0,
include_social=False
)
# Return only news portion
news_only_result = {
"ticker": result["ticker"],
"retrieved_at": result["retrieved_at"],
"news": result["news"],
"summary": {
"total_articles": result["news"]["total_articles"],
"time_range_days": days_back,
"sources": result["news"]["sources"]
}
}
logger.info(f"Successfully retrieved {result['news']['total_articles']} articles for {ticker_upper}")
return news_only_result
except ValueError as e:
logger.error(f"Invalid input for {ticker}: {e}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error fetching news for {ticker}: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve news for {ticker}. Please try again later."
)
@router.get("/{ticker}/social-only", response_model=Dict[str, Any])
async def get_ticker_social_only(
ticker: str,
days_back: int = Query(7, ge=1, le=30, description="Number of days to look back for posts (1-30)"),
max_social_posts: int = Query(20, ge=1, le=50, description="Maximum number of social media posts to return (1-50)")
):
"""
Get only social media posts for a ticker
- **ticker**: Stock ticker symbol (e.g., AAPL, TSLA, QQQ)
- **days_back**: Number of days to look back for posts (default: 7, max: 30)
- **max_social_posts**: Maximum number of social media posts to return (default: 20, min: 1, max: 50)
## Social Media Sources
- Reddit: r/stocks, r/investing, r/SecurityAnalysis, r/StockMarket, r/ValueInvesting, r/financialindependence, r/wallstreetbets
## Use Cases
- Retail investor sentiment monitoring
- Social media trend analysis
- Community discussion tracking
"""
try:
ticker_upper = ticker.upper().strip()
if not ticker_upper or len(ticker_upper) > 10:
raise HTTPException(
status_code=400,
detail=f"Invalid ticker format: {ticker}. Must be 1-10 characters."
)
logger.info(f"Fetching social-only data for {ticker_upper}")
# Get data with minimal news articles
result = await news_social_service.get_ticker_news_and_social(
ticker=ticker_upper,
days_back=days_back,
max_articles=0, # Minimal news data
max_social_posts=max_social_posts,
include_social=True
)
# Return only social media portion
social_only_result = {
"ticker": result["ticker"],
"retrieved_at": result["retrieved_at"],
"social_media": result["social_media"],
"summary": {
"total_posts": result["social_media"]["total_posts"],
"time_range_days": days_back,
"platforms": result["social_media"]["platforms"]
}
}
logger.info(f"Successfully retrieved {result['social_media']['total_posts']} social posts for {ticker_upper}")
return social_only_result
except ValueError as e:
logger.error(f"Invalid input for {ticker}: {e}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error fetching social media data for {ticker}: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve social media data for {ticker}. Please try again later."
)

@ -0,0 +1,560 @@
"""
Price data endpoints
"""
from datetime import datetime, timezone, date
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.schemas.financial import (
PriceDataRequest,
PriceDataResponse,
BulkPriceDataRequest,
BulkPriceDataResponse,
BulkPriceDataItem,
PriceDataPoint,
ErrorResponse,
ErrorType,
QuoteResponse,
IntradayResponse,
IntradayCandle,
TodayOHLCResponse,
)
from app.services.price_data_service import PriceDataService
from app.core.config import settings
from app.utils.date_utils import quarters_to_date_range
from app.utils.cache import (
build_cache_key,
get_cached_response,
set_cached_response,
)
router = APIRouter()
@router.post(
"/data",
response_model=PriceDataResponse,
responses={
400: {"model": ErrorResponse, "description": "Invalid request parameters"},
404: {"model": ErrorResponse, "description": "Data not found"},
500: {"model": ErrorResponse, "description": "Internal server error"}
},
summary="Get enhanced price data via yfinance-plus",
description="""
Retrieve historical price data for a specific ticker using enhanced yfinance-plus integration.
**🔥 Three Ways to Specify Time Period (choose one):**
1. **Period String** (NEW! Most convenient):
- `period`: "1d", "7d", "30d", "1m", "3m", "6m", "1y", "2y", "5y", "max"
- Example: `{"ticker": "AAPL", "period": "3m", "interval": "1d"}` - Last 3 months, daily prices
- Example: `{"ticker": "TSLA", "period": "max", "interval": "1d"}` - Maximum 20 years of data
2. **Date Range** (Traditional):
- `start_date` + `end_date`: Specific date range
- Example: `{"ticker": "AAPL", "start_date": "2024-01-01", "end_date": "2024-12-31", "interval": "1d"}`
3. **Quarters** (Quarter-based):
- `quarters`: List of quarters like ["2024Q1", "2024Q2"]
- Example: `{"ticker": "AAPL", "quarters": ["2024Q1", "2024Q2"], "interval": "1d"}`
**Data Source:**
- **Price Data**: Yahoo Finance via yfinance-plus with enhanced rate limiting and caching
- **Financial Data**: Available via separate financial endpoints using SEC EDGAR
**This endpoint returns:**
- OHLCV data (Open, High, Low, Close, Volume)
- Adjusted close prices with dividend/split adjustments
- Multiple intervals: 1d, 1w, 1m, 1h (where available)
- Extensive historical data (decades for most symbols)
**Enhanced Features (yfinance-plus):**
- Intelligent rate limiting to prevent API throttling
- Multi-threaded bulk downloads for better performance
- Advanced caching with cache management
- Automatic retry with exponential backoff
- Multiple user agents for improved reliability
- Enhanced error handling and recovery
**Performance:**
- Database caching to minimize external API calls
- Bulk mode capable of 59+ tickers/second throughput
- 4.3x faster than individual ticker requests
- Use `force_refresh=true` to fetch fresh data from Yahoo Finance
**Example Requests:**
```json
// Using period (simplest)
{
"ticker": "AAPL",
"period": "6m",
"interval": "1d"
}
// Using date range
{
"ticker": "TSLA",
"start_date": "2024-01-01",
"end_date": "2024-12-31",
"interval": "1w"
}
// Using quarters
{
"ticker": "NVDA",
"quarters": ["2024Q1", "2024Q2"],
"interval": "1d",
"force_refresh": true
}
```
"""
)
async def get_price_data(
request: PriceDataRequest,
response: Response,
db: AsyncSession = Depends(get_db)
):
"""Get price data for a ticker using period, quarters, or date range"""
try:
# Use the updated service that handles period resolution
price_service = PriceDataService()
# Resolve time parameters to get start and end dates
from app.utils.date_utils import resolve_time_parameters
start_date, end_date = resolve_time_parameters(
start_date=request.start_date,
end_date=request.end_date,
quarters=request.quarters,
period=request.period
)
# Build cache key (normalized to resolved dates)
cache_key = build_cache_key(
"price:data",
request.ticker.upper(),
request.interval,
start_date.date().isoformat() if start_date else "",
end_date.date().isoformat() if end_date else "",
)
# Try cache (skip if force_refresh)
if not request.force_refresh:
cached = await get_cached_response(cache_key)
if cached:
cached_body, etag = cached
response.headers["X-Cache"] = "HIT"
response.headers["Cache-Control"] = f"public, max-age={settings.CACHE_TTL}"
response.headers["ETag"] = etag
response.headers["X-Data-Source"] = "redis-cache"
return cached_body
# Check if we have existing data to determine source
missing_periods = await price_service._check_missing_periods(
db, request.ticker.upper(), start_date, end_date, request.interval
)
# Determine data source
if request.force_refresh:
data_source = "yfinance-fresh"
elif missing_periods:
data_source = "yfinance-partial"
else:
data_source = "database-cache"
# Add data source header
response.headers["X-Data-Source"] = data_source
# Get price data using resolved dates
price_data = await price_service.get_or_update_price_data(
db,
request.ticker,
start_date,
end_date,
request.interval,
request.force_refresh
)
if not price_data:
raise HTTPException(
status_code=404,
detail={
"error_type": ErrorType.DATA_NOT_FOUND,
"message": f"No price data found for ticker {request.ticker}",
"detail": {
"ticker": request.ticker,
"period": f"{start_date} to {end_date}",
"interval": request.interval
}
}
)
# Convert to response models
price_points = [
PriceDataPoint.model_validate(pd) for pd in price_data
]
# Calculate actual date range from returned data
actual_start_date = start_date
actual_end_date = end_date
if price_points:
# Get actual start and end dates from the data
actual_start_date = min(point.date for point in price_points)
actual_end_date = max(point.date for point in price_points)
body = PriceDataResponse(
ticker=request.ticker.upper(),
interval=request.interval,
data=price_points,
metadata={
"request_id": str(request.ticker),
"data_points": len(price_points),
"interval": request.interval,
"quarters_requested": request.quarters if request.quarters else None,
"date_range": {
"start": actual_start_date.isoformat(),
"end": actual_end_date.isoformat()
},
"last_updated": datetime.now(timezone.utc).isoformat()
}
)
# Cache the response body
body_dict = body.model_dump()
etag = await set_cached_response(cache_key, body_dict, ttl_seconds=settings.CACHE_TTL)
response.headers["X-Cache"] = "MISS"
response.headers["Cache-Control"] = f"public, max-age={settings.CACHE_TTL}"
response.headers["ETag"] = etag
return body
except ValueError as e:
if "Yahoo Finance data source not available" in str(e):
raise HTTPException(
status_code=503,
detail={
"error_type": ErrorType.SEC_API_ERROR,
"message": "Yahoo Finance data source not available"
}
)
raise HTTPException(
status_code=400,
detail={
"error_type": ErrorType.PARSING_ERROR,
"message": str(e)
}
)
except Exception as e:
raise HTTPException(
status_code=500,
detail={
"error_type": ErrorType.DATABASE_ERROR,
"message": "An error occurred while processing your request",
"detail": {"error": str(e)}
}
)
@router.get(
"/data/{ticker}",
response_model=PriceDataResponse,
summary="Get price data by ticker (simplified)",
description="""
Simplified GET endpoint to retrieve price data with query parameters.
**Time Period Options:**
- Use `period` for convenience: "1d", "7d", "1m", "3m", "6m", "1y", "2y", "5y", "max"
- OR use `start_date` and `end_date` for specific date range
- Cannot use both approaches simultaneously
**Examples:**
- `/api/v1/price/data/AAPL?period=1y&interval=1d` - Last year of daily prices
- `/api/v1/price/data/TSLA?period=max&interval=1d` - Maximum 20 years of data for Tesla
- `/api/v1/price/data/AAPL?start_date=2024-01-01&end_date=2024-12-31&interval=1d` - Specific date range
"""
)
async def get_price_data_simple(
ticker: str,
response: Response,
period: Optional[str] = Query(None, description="Period like '1d', '7d', '1m', '3m', '6m', '1y', '2y', '5y', 'max'"),
start_date: Optional[date] = Query(None, description="Start date for data retrieval (use with end_date, not with period)"),
end_date: Optional[date] = Query(None, description="End date for data retrieval (use with start_date, not with period)"),
interval: str = Query("1d", description="Data interval: 1d, 1w, 1m, 5d, 1h, etc."),
force_refresh: bool = Query(False, description="Force refresh from Yahoo Finance"),
db: AsyncSession = Depends(get_db)
):
"""Simplified GET endpoint for price data"""
# Validate that either period OR date range is provided, not both
if period and (start_date or end_date):
raise HTTPException(
status_code=400,
detail={
"error_type": ErrorType.VALIDATION_ERROR,
"message": "Cannot specify both period and date range. Use either period OR start_date+end_date."
}
)
if not period and not (start_date and end_date):
raise HTTPException(
status_code=400,
detail={
"error_type": ErrorType.VALIDATION_ERROR,
"message": "Must specify either period OR both start_date and end_date."
}
)
# Create request based on provided parameters
if period:
request = PriceDataRequest(
ticker=ticker,
period=period,
interval=interval,
force_refresh=force_refresh
)
else:
request = PriceDataRequest(
ticker=ticker,
start_date=start_date,
end_date=end_date,
interval=interval,
force_refresh=force_refresh
)
return await get_price_data(request, response, db)
@router.post(
"/data/bulk",
response_model=BulkPriceDataResponse,
responses={
400: {"model": ErrorResponse, "description": "Invalid request parameters"},
500: {"model": ErrorResponse, "description": "Internal server error"}
},
summary="Get enhanced price data for multiple tickers via yfinance-plus",
description="""
Retrieve historical price data for multiple tickers in a single request using enhanced yfinance-plus integration.
**🔥 Three Ways to Specify Time Period (choose one):**
1. **Period String** (NEW! Most convenient):
- `period`: "1d", "7d", "30d", "1m", "3m", "6m", "1y", "2y", "5y", "max"
- Example: Last 3 months for multiple tickers, or "max" for maximum 20 years of data
2. **Date Range** (Traditional):
- `start_date` + `end_date`: Specific date range
- Example: Specific date range for all tickers
3. **Quarters** (Quarter-based):
- `quarters`: List of quarters like ["2024Q1", "2024Q2"]
- Example: Specific quarters for all tickers
**Data Source:**
- **Price Data**: Yahoo Finance via yfinance-plus with enhanced rate limiting and caching
- **Financial Data**: Available via separate financial endpoints using SEC EDGAR
**Bulk Processing Features:**
- Processes up to 100 tickers in parallel for maximum throughput
- Returns individual success/failure results for each ticker
- Handles partial failures gracefully (some tickers can fail while others succeed)
- Uses the same enhanced data retrieval logic as single ticker endpoint
**Enhanced Performance (yfinance-plus):**
- Multi-threaded bulk downloads with intelligent rate limiting
- 4.3x faster than individual ticker requests
- Bulk mode capable of 59+ tickers/second throughput
- Advanced caching and automatic retry with exponential backoff
- Enhanced error handling and recovery mechanisms
**Data Quality:**
- OHLCV data with dividend/split adjustments
- Multiple intervals: 1d, 1w, 1m, 1h (where available)
- Extensive historical data (decades for most symbols)
- Database caching to minimize external API calls
**Example Requests:**
```json
// Using period (simplest)
{
"tickers": ["AAPL", "MSFT", "GOOGL"],
"period": "3m",
"interval": "1d"
}
// Using date range
{
"tickers": ["NVDA", "AMD", "INTC"],
"start_date": "2024-01-01",
"end_date": "2024-12-31",
"interval": "1w"
}
// Using quarters
{
"tickers": ["TSLA", "F", "GM"],
"quarters": ["2024Q1", "2024Q2"],
"interval": "1d",
"force_refresh": true
}
```
Each ticker result includes the same comprehensive price data structure as the single ticker endpoint.
Failed tickers will have detailed error messages while successful ones will have complete OHLCV data.
"""
)
async def get_bulk_price_data(
request: BulkPriceDataRequest,
db: AsyncSession = Depends(get_db)
):
"""Get price data for multiple tickers"""
# Use the updated service that handles period resolution
price_service = PriceDataService()
# Resolve time parameters to get start and end dates
from app.utils.date_utils import resolve_time_parameters
start_date, end_date = resolve_time_parameters(
start_date=request.start_date,
end_date=request.end_date,
quarters=request.quarters,
period=request.period
)
# Validate date range - ensure both dates are timezone-aware
if start_date and start_date.tzinfo is None:
start_date = start_date.replace(tzinfo=timezone.utc)
if end_date and end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=timezone.utc)
if start_date and end_date and start_date >= end_date:
raise HTTPException(
status_code=400,
detail={
"error_type": ErrorType.VALIDATION_ERROR,
"message": "Start date must be before end date"
}
)
# Future date check
current_time = datetime.now(timezone.utc)
# Make start_date timezone-aware if it's naive
if start_date and start_date.tzinfo is None:
start_date = start_date.replace(tzinfo=timezone.utc)
if start_date and start_date > current_time:
raise HTTPException(
status_code=400,
detail={
"error_type": ErrorType.INVALID_PERIOD,
"message": "Cannot request data for future dates"
}
)
# Use optimized bulk processing method
results, successful_count, failed_count = await price_service.get_multiple_tickers_data_optimized(
db=db,
tickers=request.tickers,
start_date=start_date,
end_date=end_date,
interval=request.interval,
force_refresh=request.force_refresh
)
return BulkPriceDataResponse(
results=results,
metadata={
"total_requested": len(request.tickers),
"successful": successful_count,
"failed": failed_count,
"interval": request.interval,
"quarters_requested": request.quarters if request.quarters else None,
"date_range": {
"start": start_date.isoformat(),
"end": end_date.isoformat()
},
"force_refresh": request.force_refresh,
"processed_at": datetime.now(timezone.utc).isoformat()
}
)
@router.get(
"/latest/{ticker}",
response_model=PriceDataPoint,
summary="Get latest price for a ticker",
description="Get the most recent price data point for a ticker"
)
async def get_latest_price(
ticker: str,
db: AsyncSession = Depends(get_db)
):
"""Get latest price for a ticker"""
try:
price_service = PriceDataService()
latest_price = await price_service.get_latest_price(db, ticker)
if not latest_price:
raise HTTPException(
status_code=404,
detail={
"error_type": ErrorType.DATA_NOT_FOUND,
"message": f"No price data found for ticker {ticker}"
}
)
return PriceDataPoint.model_validate(latest_price)
except Exception as e:
raise HTTPException(
status_code=500,
detail={
"error_type": ErrorType.DATABASE_ERROR,
"message": "An error occurred while processing your request",
"detail": {"error": str(e)}
}
)
@router.get(
"/quote/{ticker}",
response_model=QuoteResponse,
summary="Get latest quote (regular/pre/post)",
description="Return latest price with regular/pre/post market fields from yfinance-plus"
)
async def get_quote(
ticker: str,
use_prepost: bool = Query(True, description="Include pre/post market prices if available"),
):
svc = PriceDataService()
data = await svc.get_quote(ticker, use_prepost=use_prepost)
return QuoteResponse(**data)
@router.get(
"/intraday/{ticker}",
response_model=IntradayResponse,
summary="Get intraday candles",
description="Return intraday candles using yfinance-plus history(period,interval)"
)
async def get_intraday(
ticker: str,
interval: str = Query("1m"),
period: str = Query("1d"),
):
svc = PriceDataService()
candles = await svc.get_intraday(ticker, interval=interval, period=period)
return IntradayResponse(
ticker=ticker.upper(),
interval=interval,
period=period,
candles=[IntradayCandle(**c) for c in candles],
metadata={"count": len(candles)}
)
@router.get(
"/today/{ticker}",
response_model=TodayOHLCResponse,
summary="Get today's OHLC",
description="Return today's OHLC. If daily not finalized yet, aggregate from 1m intraday."
)
async def get_today_ohlc(
ticker: str,
):
svc = PriceDataService()
data = await svc.get_today_ohlc(ticker)
return TodayOHLCResponse(**data)

@ -0,0 +1,354 @@
"""
Request log API endpoints
"""
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, and_, or_, func
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.models.request_log import RequestLog
from app.schemas.request_log import (
RequestLogResponse,
RequestLogListResponse,
RequestLogStats
)
router = APIRouter()
@router.get(
"/logs",
response_model=RequestLogListResponse,
summary="Get request logs",
description="""
Retrieve request logs with filtering and pagination options.
**Filters:**
- Date range (start_date, end_date)
- HTTP method
- Status code range
- Endpoint pattern
- Response time range
**Sorting:**
- By date (newest first by default)
- By status code
- By response time
**Pagination:**
- Configurable page size (default: 50, max: 200)
- Page-based navigation
"""
)
async def get_request_logs(
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=200, description="Items per page"),
start_date: Optional[datetime] = Query(None, description="Filter by start date"),
end_date: Optional[datetime] = Query(None, description="Filter by end date"),
method: Optional[str] = Query(None, description="Filter by HTTP method"),
status_code: Optional[int] = Query(None, description="Filter by status code"),
endpoint: Optional[str] = Query(None, description="Filter by endpoint (supports wildcards)"),
min_response_time: Optional[float] = Query(None, description="Minimum response time in ms"),
max_response_time: Optional[float] = Query(None, description="Maximum response time in ms"),
sort_by: str = Query("created_at", description="Sort field: created_at, status_code, response_time_ms"),
sort_order: str = Query("desc", description="Sort order: asc or desc"),
db: AsyncSession = Depends(get_db)
):
"""Get paginated request logs with filters"""
# Build query
query = select(RequestLog)
# Apply filters
filters = []
if start_date:
filters.append(RequestLog.created_at >= start_date)
if end_date:
filters.append(RequestLog.created_at <= end_date)
if method:
filters.append(RequestLog.method == method.upper())
if status_code:
filters.append(RequestLog.status_code == status_code)
if endpoint:
# Support wildcard matching
if '*' in endpoint:
pattern = endpoint.replace('*', '%')
filters.append(RequestLog.endpoint.like(pattern))
else:
filters.append(RequestLog.endpoint == endpoint)
if min_response_time:
filters.append(RequestLog.response_time_ms >= min_response_time)
if max_response_time:
filters.append(RequestLog.response_time_ms <= max_response_time)
if filters:
query = query.where(and_(*filters))
# Apply sorting
sort_column = getattr(RequestLog, sort_by, RequestLog.created_at)
if sort_order.lower() == "desc":
query = query.order_by(desc(sort_column))
else:
query = query.order_by(sort_column)
# Get total count
count_query = select(func.count()).select_from(RequestLog)
if filters:
count_query = count_query.where(and_(*filters))
result = await db.execute(count_query)
total_count = result.scalar()
# Apply pagination
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
# Execute query
result = await db.execute(query)
request_logs = result.scalars().all()
# Calculate pagination info
total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 0
return RequestLogListResponse(
items=[log.to_dict() for log in request_logs],
total=total_count,
page=page,
page_size=page_size,
total_pages=total_pages
)
@router.get(
"/logs/{log_id}",
response_model=RequestLogResponse,
summary="Get request log by ID",
description="Retrieve detailed information about a specific request log"
)
async def get_request_log(
log_id: int,
db: AsyncSession = Depends(get_db)
):
"""Get specific request log by ID"""
result = await db.execute(
select(RequestLog).where(RequestLog.id == log_id)
)
request_log = result.scalar_one_or_none()
if not request_log:
raise HTTPException(
status_code=404,
detail=f"Request log with ID {log_id} not found"
)
return RequestLogResponse(**request_log.to_dict())
@router.get(
"/stats",
response_model=RequestLogStats,
summary="Get request statistics",
description="""
Get aggregated statistics about API requests.
**Statistics include:**
- Total request count
- Success/error rates
- Requests by method
- Requests by status code
- Requests by endpoint
- Time-based trends
- Average response time
"""
)
async def get_request_stats(
start_date: Optional[datetime] = Query(None, description="Start date for statistics"),
end_date: Optional[datetime] = Query(None, description="End date for statistics"),
db: AsyncSession = Depends(get_db)
):
"""Get request statistics"""
# Default to last 7 days if no dates provided
if not end_date:
end_date = datetime.now(timezone.utc)
if not start_date:
start_date = end_date - timedelta(days=7)
# Build base filter
date_filter = and_(
RequestLog.created_at >= start_date,
RequestLog.created_at <= end_date
)
# Get total count
total_result = await db.execute(
select(func.count()).select_from(RequestLog).where(date_filter)
)
total_requests = total_result.scalar()
# Get success count (2xx status codes)
success_result = await db.execute(
select(func.count()).select_from(RequestLog).where(
and_(date_filter, RequestLog.status_code.between(200, 299))
)
)
success_requests = success_result.scalar()
# Get client error count (4xx status codes)
client_error_result = await db.execute(
select(func.count()).select_from(RequestLog).where(
and_(date_filter, RequestLog.status_code.between(400, 499))
)
)
client_error_requests = client_error_result.scalar()
# Get server error count (5xx status codes)
server_error_result = await db.execute(
select(func.count()).select_from(RequestLog).where(
and_(date_filter, RequestLog.status_code.between(500, 599))
)
)
server_error_requests = server_error_result.scalar()
# Get requests by method
method_result = await db.execute(
select(
RequestLog.method,
func.count().label('count')
).where(date_filter)
.group_by(RequestLog.method)
.order_by(desc('count'))
)
requests_by_method = {row.method: row.count for row in method_result}
# Get requests by status code
status_result = await db.execute(
select(
RequestLog.status_code,
func.count().label('count')
).where(date_filter)
.group_by(RequestLog.status_code)
.order_by(desc('count'))
.limit(10)
)
requests_by_status_code = {str(row.status_code): row.count for row in status_result}
# Get requests by endpoint (top 10)
endpoint_result = await db.execute(
select(
RequestLog.endpoint,
func.count().label('count')
).where(date_filter)
.group_by(RequestLog.endpoint)
.order_by(desc('count'))
.limit(10)
)
requests_by_endpoint = {row.endpoint: row.count for row in endpoint_result}
# Get average response time
avg_time_result = await db.execute(
select(func.avg(RequestLog.response_time_ms)).where(
and_(date_filter, RequestLog.response_time_ms.isnot(None))
)
)
avg_response_time = avg_time_result.scalar() or 0
# Get hourly trend for last 24 hours if within range
hourly_trend = {}
if (end_date - start_date).days <= 1:
# SQLite specific date formatting
hourly_result = await db.execute(
select(
func.strftime('%Y-%m-%d %H:00', RequestLog.created_at).label('hour'),
func.count().label('count')
).where(date_filter)
.group_by('hour')
.order_by('hour')
)
hourly_trend = {row.hour: row.count for row in hourly_result}
return RequestLogStats(
total_requests=total_requests,
success_requests=success_requests,
client_error_requests=client_error_requests,
server_error_requests=server_error_requests,
success_rate=(success_requests / total_requests * 100) if total_requests > 0 else 0,
requests_by_method=requests_by_method,
requests_by_status_code=requests_by_status_code,
requests_by_endpoint=requests_by_endpoint,
average_response_time_ms=avg_response_time,
hourly_trend=hourly_trend,
start_date=start_date.isoformat(),
end_date=end_date.isoformat()
)
@router.delete(
"/logs/old",
summary="Delete old request logs",
description="Delete request logs older than specified days"
)
async def delete_old_request_logs(
days_old: int = Query(30, ge=1, le=365, description="Delete logs older than this many days"),
db: AsyncSession = Depends(get_db)
):
"""Delete old request logs"""
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_old)
# Get count of logs to delete
count_result = await db.execute(
select(func.count()).select_from(RequestLog).where(RequestLog.created_at < cutoff_date)
)
count = count_result.scalar()
# Delete logs
await db.execute(
RequestLog.__table__.delete().where(RequestLog.created_at < cutoff_date)
)
await db.commit()
return {
"message": f"Deleted {count} request logs older than {days_old} days",
"deleted_count": count,
"cutoff_date": cutoff_date.isoformat()
}
@router.delete(
"/logs",
summary="Delete all request logs",
description="Delete all request logs (use with caution)"
)
async def delete_all_request_logs(
confirm: bool = Query(False, description="Must be true to confirm deletion"),
db: AsyncSession = Depends(get_db)
):
"""Delete all request logs"""
if not confirm:
raise HTTPException(
status_code=400,
detail="Must set confirm=true to delete all logs"
)
# Get count of logs to delete
count_result = await db.execute(
select(func.count()).select_from(RequestLog)
)
count = count_result.scalar()
# Delete all logs
await db.execute(RequestLog.__table__.delete())
await db.commit()
return {
"message": f"Deleted all {count} request logs",
"deleted_count": count
}

@ -0,0 +1,505 @@
"""
Stock Market Data endpoints
주식 시장 데이터 관련 API 엔드포인트
"""
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Response
import logging
import asyncio
from datetime import datetime
from app.services.yahoo_most_active_service import yahoo_most_active_service
from app.services.yahoo_52week_gainers_service import yahoo_52week_gainers_service
from app.utils.cache import build_cache_key, get_cached_response, set_cached_response
from app.core.config import settings
router = APIRouter()
logger = logging.getLogger("app.api.v1.stocks")
@router.get("/most-active")
async def get_most_active_stocks(
response: Response,
limit: Optional[int] = Query(None, ge=1, le=500, description="Maximum number of stocks to return (1-500). If not specified, returns all available stocks."),
force_refresh: bool = Query(False, description="If true, bypasses cache and fetches fresh data")
):
"""
Get most actively traded stocks from Yahoo Finance
Returns real-time data of the most actively traded stocks including:
- Stock symbol and company name
- Current price information
- Price change and percentage change
- Trading volume data
- Average volume data
**Data Source**: finance.yahoo.com/markets/stocks/most-active/
**Update Frequency**: Real-time (scraped on demand)
**Rate Limiting**: Uses curl_cffi with Chrome impersonation to bypass rate limits
**Example Response**:
```json
{
"success": true,
"data": {
"stocks": [
{
"symbol": "NVDA",
"company_name": "NVIDIA Corporation",
"price_raw": "$181.96",
"change_raw": "+0.42",
"change_percent_raw": "+0.23%",
"volume_raw": "45.2M",
"avg_volume_raw": "42.1M",
"scraped_at": "2025-01-14T10:30:00"
}
],
"total_available": 168,
"returned_count": 100,
"pages_fetched": 1,
"scraped_at": "2025-01-14T10:30:00"
},
"metadata": {
"source": "finance.yahoo.com",
"endpoint": "markets/stocks/most-active",
"method": "web_scraping",
"rate_limit_bypass": "curl_cffi_chrome_impersonation"
}
}
```
**Parameters**:
- `limit`: Number of stocks to return (optional). If not specified, returns all available stocks (~170)
**Notes**:
- Data is scraped in real-time from Yahoo Finance
- Without `limit`: Returns all available stocks (typically ~170)
- With `limit`: Returns top N most active stocks
- Uses advanced rate limiting bypass techniques (curl_cffi + Chrome impersonation)
"""
try:
# Build cache key by limit parameter
cache_key = build_cache_key(
"stocks:most-active",
f"limit={limit}" if limit is not None else "limit=all"
)
# Try cache (skip if force_refresh)
if not force_refresh:
cached = await get_cached_response(cache_key)
if cached:
cached_body, etag = cached
response.headers["X-Cache"] = "HIT"
response.headers["Cache-Control"] = f"public, max-age={3600}"
response.headers["ETag"] = etag
response.headers["X-Data-Source"] = "redis-cache"
return cached_body
# Fetch fresh data
if limit is None:
logger.info("📊 Getting ALL most active stocks (no limit specified)")
result = await yahoo_most_active_service.get_all_most_active_stocks()
else:
logger.info(f"📊 Getting most active stocks (limit={limit})")
result = await yahoo_most_active_service.get_most_active_stocks(limit=limit)
if not result['success']:
logger.error(f"❌ Yahoo Finance service error: {result.get('error')}")
raise HTTPException(
status_code=503,
detail=f"Failed to fetch most active stocks: {result.get('error', 'Unknown error')}"
)
stocks_data = result['data']
count_msg = f"all {stocks_data['returned_count']}" if limit is None else f"{stocks_data['returned_count']}"
logger.info(f"✅ Successfully returned {count_msg} most active stocks")
response_body = {
"success": True,
"message": f"Retrieved {count_msg} most active stocks",
**result
}
# Set cache after successful fetch
etag = await set_cached_response(cache_key, response_body, ttl_seconds=3600)
response.headers["X-Cache"] = "MISS" if not force_refresh else "BYPASS"
response.headers["Cache-Control"] = f"public, max-age={3600}"
response.headers["ETag"] = etag
response.headers["X-Data-Source"] = "scraper"
return response_body
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Unexpected error in get_most_active_stocks: {e}")
raise HTTPException(
status_code=500,
detail=f"Internal server error while fetching most active stocks: {str(e)}"
)
@router.get("/52-week-gainers")
async def get_52week_gainers(
limit: Optional[int] = Query(None, ge=1, le=1000, description="Maximum number of stocks to return (1-1000). If not specified, returns first 600 stocks (3 pages) for performance."),
max_pages: Optional[int] = Query(3, ge=1, le=10, description="Maximum pages to fetch (1-10). Each page has ~200 stocks. Higher values may cause rate limiting.")
):
"""
Get 52-week top gaining stocks from Yahoo Finance
Returns stocks with highest 52-week price gains including:
- Stock symbol and company name
- Current price and 52-week high
- Price change amount and percentage
- Trading volume data
- Gain percentages over 52-week period
**Data Source**: finance.yahoo.com/markets/stocks/52-week-gainers/
**Total Available**: ~1,350 stocks across 7 pages
**Update Frequency**: Real-time (scraped on demand)
**Rate Limiting**: Intelligent delays between requests to avoid blocking
**Example Response**:
```json
{
"success": true,
"data": {
"stocks": [
{
"symbol": "EXAMPLE",
"company_name": "Example Corp",
"current_price": "10.50",
"change_percent": "+150.00%",
"high_52w": "11.00",
"volume": "1.2M"
}
],
"total_available": 1350,
"returned_count": 200,
"elapsed_time_seconds": 15.2
}
}
```
**Parameters**:
- `limit`: Number of stocks to return (optional). Default: returns ~600 stocks (3 pages)
- `max_pages`: Maximum pages to scrape (1-10). Higher values take longer and may hit rate limits
**Performance Notes**:
- Default (3 pages): ~15-30 seconds, 600 stocks
- All pages (7 pages): ~45-90 seconds, 1,350 stocks
- Intelligent rate limiting with progressive delays
- Session management to avoid detection
- Automatic retry logic for failed requests
**Rate Limiting Strategy**:
- 1-3 second delays between requests
- 5+ second delays every 3 requests
- Progressive delays for later pages
- Session rotation every 5 minutes
"""
try:
if limit is None:
logger.info(f"📊 Getting 52-week gainers (default: {max_pages} pages)")
result = await yahoo_52week_gainers_service.get_52week_gainers(limit=None, max_pages=max_pages)
else:
logger.info(f"📊 Getting 52-week gainers (limit={limit}, max_pages={max_pages})")
result = await yahoo_52week_gainers_service.get_52week_gainers(limit=limit, max_pages=max_pages)
if not result['success']:
logger.error(f"❌ Yahoo Finance 52-week gainers error: {result.get('error')}")
raise HTTPException(
status_code=503,
detail=f"Failed to fetch 52-week gainers: {result.get('error', 'Unknown error')}"
)
stocks_data = result['data']
count_msg = f"all {stocks_data['returned_count']}" if limit is None else f"{stocks_data['returned_count']}"
elapsed = stocks_data.get('elapsed_time_seconds', 0)
logger.info(f"✅ Successfully returned {count_msg} 52-week gainers in {elapsed}s")
return {
"success": True,
"message": f"Retrieved {count_msg} 52-week gaining stocks in {elapsed}s",
**result
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Unexpected error in get_52week_gainers: {e}")
raise HTTPException(
status_code=500,
detail=f"Internal server error while fetching 52-week gainers: {str(e)}"
)
@router.get("/trending")
async def get_trending_stocks(
n: Optional[int] = Query(500, ge=1, description="Total number of trending stocks to return after combining most active + gainers (default: 500)"),
most_active_limit: Optional[int] = Query(None, ge=1, description="Number of most active stocks to include. If not specified, returns all available stocks (~170)."),
gainers_limit: Optional[int] = Query(None, ge=1, description="Number of 52-week gainers to fetch. If not specified, fetches enough to reach target 'n' after combining with most active.")
):
"""
Get trending stocks combining most active and 52-week gainers
Returns a comprehensive list of trending stocks by combining:
- Most actively traded stocks (high volume, immediate market interest)
- Top 52-week gainers (strong long-term performance)
**Data Sources**:
- Most Active: finance.yahoo.com/markets/stocks/most-active/
- 52-Week Gainers: finance.yahoo.com/markets/stocks/52-week-gainers/
**Update Frequency**: Real-time (scraped on demand)
**Rate Limiting**: Optimized parallel fetching with intelligent delays
**Example Response**:
```json
{
"success": true,
"message": "Retrieved 500 trending stocks (170 most active + 330 gainers) in 18.5s",
"data": {
"trending_stocks": [
{
"symbol": "NVDA",
"company_name": "NVIDIA Corporation",
"current_price": "181.96",
"change_amount": "+0.42",
"change_percent": "+0.23%",
"volume": "45.2M",
"category": "most_active",
"rank_in_category": 1
},
{
"symbol": "TSLA",
"company_name": "Tesla Inc",
"current_price": "248.50",
"change_amount": "+12.30",
"change_percent": "+125.50%",
"volume": "2.1M",
"high_52w": "250.00",
"category": "52_week_gainer",
"rank_in_category": 1
}
],
"summary": {
"total_stocks": 500,
"most_active_count": 170,
"gainers_count": 330,
"unique_symbols": 485,
"overlap_count": 15
},
"performance": {
"elapsed_time_seconds": 18.5,
"most_active_time": 3.1,
"gainers_time": 15.4,
"parallel_execution": true
}
},
"metadata": {
"sources": ["finance.yahoo.com/most-active", "finance.yahoo.com/52-week-gainers"],
"method": "parallel_scraping_with_intelligent_rate_limiting",
"categories": ["most_active", "52_week_gainer"]
}
}
```
**Parameters**:
- `n`: Total number of trending stocks to return (default: 500). Final result is limited to this number.
- `most_active_limit`: Number of most active stocks to include (default: all available ~170 stocks)
- `gainers_limit`: Number of 52-week gainers to fetch (default: calculated to reach target `n`)
**Parameter Coordination**:
- **Default Behavior**: `n=500`, fetches all most active (~170) + calculates gainers needed (~330)
- **Custom Total**: Set `n` to control final result size, other parameters auto-adjust
- **Custom Mix**: Specify `most_active_limit` and/or `gainers_limit` for precise control
- **Priority**: most_active stocks prioritized, then gainers by rank when limiting to `n`
**Performance Notes**:
- **Default Mode** (n=500): ~15-30 seconds for 500 trending stocks
- **Fast Mode** (n=200): ~5-10 seconds for 200 trending stocks
- **Comprehensive Mode** (n=1000+): ~30-60 seconds for large datasets
- Automatic pagination based on calculated gainers_limit (approximately 200 stocks per page)
- Parallel execution for optimal performance
- Smart deduplication to handle overlapping stocks
**Categories**:
- `most_active`: High trading volume, immediate market attention
- `52_week_gainer`: Strong long-term price performance
- Stocks may appear in both categories (indicated by overlap_count)
"""
try:
start_time = datetime.now()
# Parameter coordination logic
# 1. If most_active_limit is None, we'll get all available (~170)
expected_most_active = most_active_limit if most_active_limit is not None else 170
# 2. Calculate gainers_limit if not specified to reach target n
if gainers_limit is None:
# Calculate how many gainers we need to reach target n
target_gainers = max(50, n - expected_most_active) # At least 50 gainers
else:
target_gainers = gainers_limit
# 3. Calculate pages needed for gainers (approximately 200 stocks per page)
gainers_pages = min(max(1, (target_gainers + 199) // 200), 7) # Ceiling division, max 7 pages
logger.info(f"🔥 Getting trending stocks (n={n}, most_active={most_active_limit or 'all'}, target_gainers={target_gainers}, auto_pages={gainers_pages})")
# Parallel execution for better performance
most_active_task = yahoo_most_active_service.get_most_active_stocks(limit=most_active_limit)
gainers_task = yahoo_52week_gainers_service.get_52week_gainers(limit=target_gainers, max_pages=gainers_pages)
# Wait for both tasks to complete
most_active_result, gainers_result = await asyncio.gather(most_active_task, gainers_task)
# Check for errors
if not most_active_result['success']:
logger.error(f"❌ Most active stocks error: {most_active_result.get('error')}")
raise HTTPException(
status_code=503,
detail=f"Failed to fetch most active stocks: {most_active_result.get('error', 'Unknown error')}"
)
if not gainers_result['success']:
logger.error(f"❌ 52-week gainers error: {gainers_result.get('error')}")
raise HTTPException(
status_code=503,
detail=f"Failed to fetch 52-week gainers: {gainers_result.get('error', 'Unknown error')}"
)
# Extract data
most_active_stocks = most_active_result['data']['stocks']
gainers_stocks = gainers_result['data']['stocks']
# Track timing
most_active_time = most_active_result['data'].get('elapsed_time_seconds', 0)
gainers_time = gainers_result['data'].get('elapsed_time_seconds', 0)
# Normalize and categorize stocks
trending_stocks = []
seen_symbols = set()
overlap_count = 0
# Add most active stocks
for i, stock in enumerate(most_active_stocks[:most_active_limit]):
symbol = stock.get('symbol', '').upper()
if symbol:
trending_stock = {
'symbol': symbol,
'company_name': stock.get('company_name', 'N/A'),
'current_price': stock.get('current_price', stock.get('price_raw', 'N/A')),
'change_amount': stock.get('change_amount', stock.get('change_raw', 'N/A')),
'change_percent': stock.get('change_percent', stock.get('change_percent_raw', 'N/A')),
'volume': stock.get('volume', stock.get('volume_raw', 'N/A')),
'category': 'most_active',
'rank_in_category': i + 1,
'scraped_at': stock.get('scraped_at')
}
# Add average volume if available
if 'avg_volume' in stock or 'avg_volume_raw' in stock:
trending_stock['avg_volume'] = stock.get('avg_volume', stock.get('avg_volume_raw'))
trending_stocks.append(trending_stock)
seen_symbols.add(symbol)
# Add 52-week gainers
for i, stock in enumerate(gainers_stocks[:gainers_limit]):
symbol = stock.get('symbol', '').upper()
if symbol:
# Check for overlap
is_overlap = symbol in seen_symbols
if is_overlap:
overlap_count += 1
# Find and update existing stock to indicate it's in both categories
for existing_stock in trending_stocks:
if existing_stock['symbol'] == symbol:
existing_stock['category'] = 'both'
existing_stock['gainer_rank'] = i + 1
# Add 52w high if available
if 'high_52w' in stock:
existing_stock['high_52w'] = stock['high_52w']
break
else:
trending_stock = {
'symbol': symbol,
'company_name': stock.get('company_name', 'N/A'),
'current_price': stock.get('current_price', 'N/A'),
'change_amount': stock.get('change_amount', 'N/A'),
'change_percent': stock.get('change_percent', 'N/A'),
'volume': stock.get('volume', 'N/A'),
'category': '52_week_gainer',
'rank_in_category': i + 1,
'scraped_at': stock.get('scraped_at')
}
# Add 52w high if available
if 'high_52w' in stock:
trending_stock['high_52w'] = stock['high_52w']
trending_stocks.append(trending_stock)
seen_symbols.add(symbol)
# Limit final result to n stocks (prioritize most_active, then gainers by rank)
if len(trending_stocks) > n:
# Sort to prioritize most_active and low ranks
trending_stocks.sort(key=lambda x: (
x['category'] != 'most_active', # most_active first
x['category'] == '52_week_gainer', # then gainers
x['rank_in_category'] # then by rank within category
))
trending_stocks = trending_stocks[:n]
# Calculate final metrics
total_time = (datetime.now() - start_time).total_seconds()
unique_symbols = len(set(stock['symbol'] for stock in trending_stocks))
most_active_count = len([s for s in trending_stocks if s['category'] in ['most_active', 'both']])
gainers_count = len([s for s in trending_stocks if s['category'] in ['52_week_gainer', 'both']])
logger.info(f"✅ Successfully returned {len(trending_stocks)} trending stocks (target: {n}, unique: {unique_symbols}) in {total_time:.1f}s")
return {
"success": True,
"message": f"Retrieved {len(trending_stocks)} trending stocks ({most_active_count} most active + {gainers_count} gainers) in {total_time:.1f}s",
"data": {
"trending_stocks": trending_stocks,
"summary": {
"total_stocks": len(trending_stocks),
"most_active_count": most_active_count,
"gainers_count": gainers_count,
"unique_symbols": unique_symbols,
"overlap_count": overlap_count
},
"performance": {
"elapsed_time_seconds": round(total_time, 1),
"most_active_time": round(most_active_time, 1),
"gainers_time": round(gainers_time, 1),
"parallel_execution": True
},
"scraped_at": datetime.now().isoformat()
},
"metadata": {
"sources": [
"finance.yahoo.com/markets/stocks/most-active/",
"finance.yahoo.com/markets/stocks/52-week-gainers/"
],
"method": "parallel_scraping_with_intelligent_rate_limiting",
"categories": ["most_active", "52_week_gainer", "both"],
"rate_limit_bypass": "curl_cffi_chrome_impersonation",
"deduplication": "symbol_based_with_category_merge"
}
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Unexpected error in get_trending_stocks: {e}")
raise HTTPException(
status_code=500,
detail=f"Internal server error while fetching trending stocks: {str(e)}"
)

@ -0,0 +1,127 @@
"""
Configuration settings for Stock Oracle API
"""
from typing import List, Union, Dict
from pydantic import AnyHttpUrl, field_validator
try:
from pydantic_settings import BaseSettings
except ImportError:
from pydantic import BaseSettings
import os
import json
from dotenv import load_dotenv
load_dotenv()
class Settings(BaseSettings):
# Application
APP_NAME: str = "Stock Oracle"
APP_VERSION: str = "1.0.0"
DEBUG: bool = True
ENVIRONMENT: str = "development"
# API
API_PREFIX: str = "/api/v1"
# CORS
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
@field_validator("BACKEND_CORS_ORIGINS", mode="before")
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
elif isinstance(v, (list, str)):
return v
raise ValueError(v)
# Database
DATABASE_URL: str = os.getenv(
"DATABASE_URL",
"sqlite+aiosqlite:///./stock_oracle.db"
)
DATABASE_ECHO: bool = False
# Redis
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:16379/0")
CACHE_TTL: int = 3600 # 1 hour default
# SEC Settings
SEC_EMAIL: str = os.getenv("SEC_EMAIL", "example@example.com")
SEC_DATA_REFRESH_HOURS: int = 24
SEC_DATA_START_YEAR: int = 1994 # SEC EDGAR data available from 1994
# Security
SECRET_KEY: str = os.getenv("SECRET_KEY", "development-secret-key-change-in-production")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# ETF Scraper integration
ETF_SCRAPER_TICKERS: Union[List[str], str] = ['MTUM'] # e.g., ["MTUM", "QTUM"] or "MTUM,QTUM"
@field_validator("ETF_SCRAPER_TICKERS", mode="before")
def parse_scraper_tickers(cls, v):
if isinstance(v, str):
if not v:
return []
return [t.strip().upper() for t in v.split(",") if t.strip()]
if isinstance(v, list):
return [str(t).strip().upper() for t in v]
return []
# Manual ETF inception/start dates (ISO YYYY-MM-DD), used to short-circuit pre-launch requests
# Accepts either a JSON string or a comma-separated "TICKER:YYYY-MM-DD" list via env
ETF_START_DATES: Dict[str, str] = {"MTUM": "2013-04-16"}
@field_validator("ETF_START_DATES", mode="before")
def parse_etf_start_dates(cls, v):
# Examples:
# "{\"MTUM\": \"2013-04-16\", \"QQQ\": \"1999-03-10\"}"
# "MTUM:2013-04-16,QQQ:1999-03-10"
if isinstance(v, str):
v = v.strip()
if not v:
return {}
try:
data = json.loads(v)
if isinstance(data, dict):
return {str(k).strip().upper(): str(val).strip() for k, val in data.items() if str(val).strip()}
except Exception:
pass
items = {}
for part in v.split(','):
if not part.strip():
continue
if ':' in part:
k, val = part.split(':', 1)
k = k.strip().upper()
val = val.strip()
if k and val:
items[k] = val
return items
if isinstance(v, dict):
return {str(k).strip().upper(): str(val).strip() for k, val in v.items() if str(val).strip()}
return {}
# Server
API_PORT: int = int(os.getenv("API_PORT", "18000"))
DB_PORT: int = int(os.getenv("DB_PORT", "15432"))
REDIS_PORT: int = int(os.getenv("REDIS_PORT", "16379"))
# Migration
MIGRATION_API_KEY: str = os.getenv("MIGRATION_API_KEY", "migration-key-change-in-production")
ALLOW_MIGRATION: bool = os.getenv("ALLOW_MIGRATION", "True").lower() == "true"
class Config:
case_sensitive = True
env_file = ".env"
settings = Settings()
# Warn about default secrets in non-development environments
import logging as _logging
_config_logger = _logging.getLogger(__name__)
if settings.ENVIRONMENT != "development":
if settings.SECRET_KEY == "development-secret-key-change-in-production":
_config_logger.warning("SECRET_KEY is using the default value! Set a secure SECRET_KEY for production.")
if settings.MIGRATION_API_KEY == "migration-key-change-in-production":
_config_logger.warning("MIGRATION_API_KEY is using the default value! Set a secure MIGRATION_API_KEY for production.")

@ -0,0 +1,53 @@
"""
Database configuration and session management
"""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from sqlalchemy.pool import NullPool
from app.core.config import settings
# Conditional engine settings based on database type
_is_sqlite = settings.DATABASE_URL.startswith("sqlite")
if _is_sqlite:
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DATABASE_ECHO,
future=True,
poolclass=NullPool,
)
else:
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DATABASE_ECHO,
future=True,
pool_size=10,
max_overflow=20,
pool_timeout=30,
pool_pre_ping=True,
pool_recycle=3600,
connect_args={
"server_settings": {
"jit": "off"
}
}
)
# Create async session factory
AsyncSessionLocal = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
# Create base class for models
Base = declarative_base()
# Dependency to get DB session
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()

@ -0,0 +1,285 @@
"""
Main FastAPI application
"""
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse, HTMLResponse
from app.core.config import settings
from app.api.v1.api import api_router
from app.core.database import engine, Base
from app.middleware.error_logger import ErrorLoggingMiddleware
from app.models import error_log, request_log, fred_data # Import to register models
# Create database tables
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup - ensure tables exist
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
# Shutdown
await engine.dispose()
# Create FastAPI app
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
openapi_url=f"{settings.API_PREFIX}/openapi.json",
docs_url=f"{settings.API_PREFIX}/docs",
redoc_url=f"{settings.API_PREFIX}/redoc",
lifespan=lifespan
)
# Add error logging middleware
app.add_middleware(ErrorLoggingMiddleware)
# Set up CORS - use configured origins if available, otherwise allow all
cors_origins = [str(o) for o in settings.BACKEND_CORS_ORIGINS] if settings.BACKEND_CORS_ORIGINS else ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API router
app.include_router(api_router, prefix=settings.API_PREFIX)
# Root documentation endpoint
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
async def root_documentation():
"""
Display comprehensive API documentation at root path
"""
try:
# Simple working version
simple_html = f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stock Oracle API Documentation</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
max-width: 1000px;
margin: 0 auto;
padding: 20px;
color: #333;
}}
h1 {{ color: #007acc; border-bottom: 2px solid #007acc; padding-bottom: 10px; }}
h2 {{ color: #2c3e50; margin-top: 2em; }}
.nav-links {{ margin: 20px 0; }}
.nav-links a {{
display: inline-block;
margin-right: 15px;
padding: 10px 20px;
background: #007acc;
color: white;
text-decoration: none;
border-radius: 5px;
}}
.nav-links a:hover {{ background: #0066aa; }}
code {{ background: #f4f4f4; padding: 2px 4px; border-radius: 3px; }}
pre {{ background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; }}
.new-badge {{ background: #e74c3c; color: white; padding: 2px 6px; border-radius: 3px; font-size: 0.8em; }}
</style>
</head>
<body>
<h1>🔮 Stock Oracle API Documentation</h1>
<p><strong>Comprehensive Investment Data Analysis API</strong></p>
<div class="nav-links">
<a href="{settings.API_PREFIX}/docs">📋 Interactive API Docs</a>
<a href="{settings.API_PREFIX}/redoc">📖 ReDoc</a>
<a href="{settings.API_PREFIX}/health">💚 Health Check</a>
<a href="{settings.API_PREFIX}/openapi.json">⚙️ OpenAPI Schema</a>
</div>
<h2>🚀 Quick Start</h2>
<p><strong>Base URL:</strong> <code>http://localhost:18001/api/v1</code></p>
<h2>🎯 Main Endpoints</h2>
<h3>Health & System</h3>
<ul>
<li><code>GET /health</code> - API health status</li>
<li><code>GET /health/detailed</code> - Detailed system health</li>
</ul>
<h3>Financial Data</h3>
<ul>
<li><code>POST /financial/data</code> - Get comprehensive financial data</li>
<li><code>GET /financial/data/{{ticker}}</code> - Simple financial data</li>
<li><code>POST /financial/data/bulk</code> - Bulk financial data</li>
</ul>
<h3>Price Data</h3>
<ul>
<li><code>POST /price/data</code> - Get historical price data (OHLCV)</li>
<li><code>GET /price/data/{{ticker}}</code> - Simple price data</li>
<li><code>POST /price/data/bulk</code> - Bulk price data</li>
<li><code>GET /price/quote/{{ticker}}</code> - Latest quote (regular/pre/post market)</li>
<li><code>GET /price/intraday/{{ticker}}</code> - Intraday candles (interval, period)</li>
<li><code>GET /price/today/{{ticker}}</code> - Today's OHLC (daily or 1m aggregate)</li>
</ul>
<h3>Stock Market Data <span class="new-badge">NEW</span></h3>
<ul>
<li><code>GET /stocks/trending</code> - Trending stocks with intelligent parameter coordination (n=500 default)</li>
<li><code>GET /stocks/most-active</code> - Most actively traded stocks</li>
<li><code>GET /stocks/52-week-gainers</code> - Top 52-week gaining stocks</li>
</ul>
<h3>FRED Economic Data <span class="new-badge">NEW</span></h3>
<ul>
<li><code>GET /fred/proxy/{{endpoint}}</code> - Universal FRED API proxy with caching</li>
<li><code>GET /fred/endpoints</code> - List all supported FRED API endpoints</li>
<li><code>GET /fred/stats/usage</code> - API usage statistics and monitoring</li>
</ul>
<h3>News & Social Media <span class="new-badge">NEW</span></h3>
<ul>
<li><code>GET /news/{{ticker}}</code> - Complete news and social media data</li>
<li><code>GET /news/{{ticker}}/news-only</code> - News articles only (faster)</li>
<li><code>GET /news/{{ticker}}/social-only</code> - Social media posts only</li>
</ul>
<h3>ETF Holdings</h3>
<ul>
<li><code>GET /etf/holdings/{{ticker}}</code> - ETF holdings at date or most recent</li>
<li><code>POST /etf/admin/refresh-maps</code> - Refresh CUSIP/CIK maps</li>
</ul>
<h2>📊 Example Requests</h2>
<h3>Financial Data</h3>
<pre><code>curl -X POST "http://localhost:18001/api/v1/financial/data" \\
-H "Content-Type: application/json" \\
-d '{{"ticker": "AAPL", "period": "1y", "include_metrics": true}}'</code></pre>
<h3>Trending Stocks <span class="new-badge">NEW</span></h3>
<pre><code># Get trending stocks (default: 500 total stocks with intelligent coordination)
curl "http://localhost:18001/api/v1/stocks/trending"
# Custom total count
curl "http://localhost:18001/api/v1/stocks/trending?n=200"</code></pre>
<h3>FRED Economic Data <span class="new-badge">NEW</span></h3>
<pre><code># Get GDP series information
curl "http://localhost:18001/api/v1/fred/proxy/series?series_id=GDP"
# Get unemployment rate observations
curl "http://localhost:18001/api/v1/fred/proxy/series/observations?series_id=UNRATE&limit=12"</code></pre>
<h3>News & Social Data <span class="new-badge">NEW</span></h3>
<pre><code>curl "http://localhost:18001/api/v1/news/AAPL?days_back=7&max_articles=20"</code></pre>
<h3>Price - Quote/Intraday/Today <span class="new-badge">NEW</span></h3>
<pre><code># Quote (latest regular/pre/post)
curl "http://localhost:18001/api/v1/price/quote/AAPL?use_prepost=true"
# Intraday 1m candles for 1 day
curl "http://localhost:18001/api/v1/price/intraday/AAPL?interval=1m&period=1d"
# Today's OHLC (daily if available; otherwise 1m aggregate)
curl "http://localhost:18001/api/v1/price/today/AAPL"</code></pre>
<h3>ETF Holdings</h3>
<pre><code>curl "http://localhost:18001/api/v1/etf/holdings/QQQ"</code></pre>
<h2>🐍 Python Client</h2>
<pre><code>from stock_oracle_client import StockOracleClient
client = StockOracleClient("http://localhost:18001")
# Check health
health = client.get_health()
print("API Status:", health["status"])
# Get financial data
data = client.get_financial_data("AAPL", period="1y")
# Get news data (NEW!)
news = client.get_news_social_data("AAPL", days_back=7)</code></pre>
<h2>📈 Key Features</h2>
<ul>
<li><strong>SEC EDGAR Data</strong> - Official company filings (10-K, 10-Q)</li>
<li><strong>Real-time News</strong> - Yahoo Finance + NewsAPI integration</li>
<li><strong>Social Sentiment</strong> - Reddit discussions and sentiment analysis</li>
<li><strong>ETF Holdings</strong> - Complete ETF portfolio analysis via N-PORT</li>
<li><strong>Price Data</strong> - Historical OHLCV data from Yahoo Finance</li>
<li><strong>Investment Metrics</strong> - P/E, ROE, debt ratios, growth metrics</li>
</ul>
<h2>🔧 Data Sources</h2>
<ul>
<li><strong>SEC EDGAR</strong> - Official company filings and ETF holdings</li>
<li><strong>Yahoo Finance</strong> - Price data and financial news (via yfinance_plus)</li>
<li><strong>NewsAPI</strong> - Professional news aggregation</li>
<li><strong>Reddit API</strong> - Social media sentiment from investing subreddits</li>
</ul>
<footer style="margin-top: 3em; padding-top: 2em; border-top: 1px solid #eee; text-align: center; color: #666;">
<p><strong>Stock Oracle API</strong> - Built with FastAPI, powered by SEC EDGAR data</p>
<p>For complete interactive documentation, visit <a href="{settings.API_PREFIX}/docs">Swagger UI</a></p>
<p><small>Version {settings.APP_VERSION}</small></p>
</footer>
</body>
</html>
"""
return HTMLResponse(content=simple_html)
except Exception as e:
# Fallback to Swagger UI if anything goes wrong
return RedirectResponse(url=f"{settings.API_PREFIX}/docs")
# Additional metadata for OpenAPI
app.openapi_tags = [
{
"name": "health",
"description": "Health check endpoints"
},
{
"name": "financial",
"description": "Financial data retrieval endpoints"
},
{
"name": "price",
"description": "Price data endpoints (OHLCV)"
},
{
"name": "news",
"description": "News and social media endpoints"
},
{
"name": "metadata",
"description": "Data catalog and metadata endpoints"
},
{
"name": "etf",
"description": "ETF holdings endpoints"
},
{
"name": "admin",
"description": "Administrative endpoints (migration, etc.)"
}
]
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=settings.API_PORT,
reload=settings.DEBUG
)

@ -0,0 +1,324 @@
"""
Error logging middleware for capturing and storing API errors
"""
import json
import time
import traceback
import uuid
from datetime import datetime, timezone
from typing import Callable, Optional
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
from app.core.database import get_db
from app.models.error_log import ErrorLog
from app.models.request_log import RequestLog
import logging
logger = logging.getLogger(__name__)
class ErrorLoggingMiddleware(BaseHTTPMiddleware):
"""Middleware to log all API requests and errors to database"""
def __init__(self, app: ASGIApp):
super().__init__(app)
async def dispatch(self, request: Request, call_next: Callable) -> Response:
"""Process request and log any errors that occur"""
# Generate unique request ID
request_id = str(uuid.uuid4())[:8]
request.state.request_id = request_id
# Track request start time
start_time = time.time()
# Store request details for potential error logging
request_info = await self._extract_request_info(request)
logger.info(f"Processing request {request_id}: {request.method} {request.url.path}")
try:
# Process the request
response = await call_next(request)
# Calculate response time
response_time_ms = (time.time() - start_time) * 1000
# Store response body for error cases
response_body = b""
error_detail = None
# Check if response indicates an error (4xx or 5xx)
if response.status_code >= 400:
logger.info(f"Error response detected: {response.status_code} for request {request_id}")
# Try to capture response body for errors
# We need to consume the response body and recreate it
from starlette.responses import Response
# Collect response body chunks
body_chunks = []
async for chunk in response.body_iterator:
body_chunks.append(chunk)
response_body = b''.join(body_chunks)
# Try to parse as JSON
try:
if response_body:
error_detail = json.loads(response_body.decode('utf-8'))
except Exception as e:
logger.warning(f"Could not parse error response body as JSON: {e}")
# Store raw text if not JSON
try:
error_detail = {"raw_response": response_body.decode('utf-8')}
except:
error_detail = {"raw_response": str(response_body)}
# Log the error with response body
logger.info(f"Logging error for request {request_id}")
await self._log_error(
request_id=request_id,
request_info=request_info,
status_code=response.status_code,
error_detail=error_detail,
response_time_ms=response_time_ms
)
# Recreate response with the same body
response = Response(
content=response_body,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.media_type
)
# Log all requests (not just errors)
# Add data source info to headers for successful responses
data_source = response.headers.get("X-Data-Source", None)
await self._log_request(
request_id=request_id,
request_info=request_info,
status_code=response.status_code,
response_time_ms=response_time_ms,
response_size=len(response_body) if response_body else None,
data_source=data_source
)
# Add request ID to response headers
response.headers["X-Request-ID"] = request_id
return response
except Exception as e:
# Log unexpected errors
response_time_ms = (time.time() - start_time) * 1000
await self._log_error(
request_id=request_id,
request_info=request_info,
status_code=500,
error_type="INTERNAL_SERVER_ERROR",
error_message=str(e),
stack_trace=traceback.format_exc(),
response_time_ms=response_time_ms
)
# Return error response
return JSONResponse(
status_code=500,
content={
"error_type": "INTERNAL_SERVER_ERROR",
"message": "An unexpected error occurred",
"request_id": request_id,
"timestamp": datetime.now(timezone.utc).isoformat()
},
headers={"X-Request-ID": request_id}
)
async def _extract_request_info(self, request: Request) -> dict:
"""Extract request information for logging"""
# Get request body if present
body = None
if request.method in ["POST", "PUT", "PATCH"]:
try:
body_bytes = await request.body()
if body_bytes:
body = json.loads(body_bytes.decode('utf-8'))
# Store body for later use in request processing
request._body = body_bytes
except Exception as e:
logger.warning(f"Could not parse request body: {e}")
# Extract headers (remove sensitive ones)
headers = dict(request.headers)
sensitive_headers = ['authorization', 'api-key', 'x-api-key', 'cookie']
for header in sensitive_headers:
if header in headers:
headers[header] = '***REDACTED***'
return {
"endpoint": str(request.url.path),
"method": request.method,
"path": str(request.url),
"query_params": dict(request.query_params) if request.query_params else None,
"request_body": body,
"headers": headers,
"user_agent": headers.get("user-agent"),
"client_ip": request.client.host if request.client else None
}
async def _log_error(
self,
request_id: str,
request_info: dict,
status_code: int,
error_type: Optional[str] = None,
error_message: Optional[str] = None,
error_detail: Optional[dict] = None,
stack_trace: Optional[str] = None,
response_time_ms: Optional[float] = None
):
"""Log error to database"""
logger.info(f"_log_error called for request {request_id}, status {status_code}")
# Skip logging errors for log deletion endpoints to avoid logging the deletion of logs
endpoint = request_info["endpoint"]
method = request_info["method"]
# Don't log errors for DELETE requests to log management endpoints
if (method == "DELETE" and
(endpoint.startswith("/api/v1/admin/requests/logs") or
endpoint.startswith("/api/v1/admin/errors/logs"))):
logger.info(f"Skipping error log for log deletion endpoint: {method} {endpoint}")
return
try:
# Get database session
from app.core.database import AsyncSessionLocal
logger.info(f"Creating database session for request {request_id}")
async with AsyncSessionLocal() as db:
# Extract error information from detail if available
if error_detail and isinstance(error_detail, dict):
if not error_type:
error_type = error_detail.get("error_type", "UNKNOWN_ERROR")
if not error_message:
error_message = error_detail.get("message", "Unknown error occurred")
# Set defaults
if not error_type:
if status_code >= 500:
error_type = "INTERNAL_SERVER_ERROR"
elif status_code >= 400:
error_type = "CLIENT_ERROR"
else:
error_type = "UNKNOWN_ERROR"
if not error_message:
error_message = f"HTTP {status_code} error"
# Create error log entry
error_log = ErrorLog(
request_id=request_id,
endpoint=request_info["endpoint"],
method=request_info["method"],
path=request_info["path"],
query_params=request_info["query_params"],
request_body=request_info["request_body"],
headers=request_info["headers"],
error_type=error_type,
error_message=error_message,
error_detail=error_detail,
status_code=status_code,
stack_trace=stack_trace,
user_agent=request_info["user_agent"],
client_ip=request_info["client_ip"],
response_time_ms=response_time_ms
)
db.add(error_log)
logger.info(f"Error log added to session for request {request_id}")
await db.commit()
logger.info(f"Error log committed to database for request {request_id}")
logger.error(
f"Error logged - Request ID: {request_id}, "
f"Endpoint: {request_info['endpoint']}, "
f"Status: {status_code}, "
f"Error: {error_type} - {error_message}"
)
except Exception as e:
# If we can't log to database, at least log to file
logger.error(f"Failed to log error to database: {e}")
logger.error(
f"Original error - Request ID: {request_id}, "
f"Status: {status_code}, "
f"Error: {error_type} - {error_message}"
)
async def _log_request(
self,
request_id: str,
request_info: dict,
status_code: int,
response_time_ms: Optional[float] = None,
response_size: Optional[int] = None,
data_source: Optional[str] = None
):
"""Log general request to database"""
# Skip logging for log deletion endpoints to avoid logging the deletion of logs
endpoint = request_info["endpoint"]
method = request_info["method"]
# Don't log DELETE requests to log management endpoints
if (method == "DELETE" and
(endpoint.startswith("/api/v1/admin/requests/logs") or
endpoint.startswith("/api/v1/admin/errors/logs"))):
logger.info(f"Skipping log for log deletion endpoint: {method} {endpoint}")
return
try:
# Get database session
from app.core.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
# Store data source in headers if available
if data_source and request_info.get("headers"):
request_info["headers"]["X-Data-Source"] = data_source
# Create request log entry
request_log = RequestLog(
request_id=request_id,
endpoint=request_info["endpoint"],
method=request_info["method"],
path=request_info["path"],
query_params=request_info["query_params"],
request_body=request_info["request_body"],
headers=request_info["headers"],
status_code=status_code,
response_size=response_size,
user_agent=request_info["user_agent"],
client_ip=request_info["client_ip"],
response_time_ms=response_time_ms
)
db.add(request_log)
await db.commit()
except Exception as e:
# If we can't log to database, at least log to file
logger.error(f"Failed to log request to database: {e}")
logger.info(
f"Request log - Request ID: {request_id}, "
f"Endpoint: {request_info['endpoint']}, "
f"Status: {status_code}, "
f"Response Time: {response_time_ms}ms"
)

@ -0,0 +1,15 @@
from app.models.financial import Company, FinancialData, CalculatedMetrics, PriceData, DataUpdateLog
from app.models.etf import CusipMap, ETFCIKMap, ETFSeriesMap, ETFHoldingsSnapshot, ETFHolding
__all__ = [
"Company",
"FinancialData",
"CalculatedMetrics",
"PriceData",
"DataUpdateLog",
"CusipMap",
"ETFCIKMap",
"ETFSeriesMap",
"ETFHoldingsSnapshot",
"ETFHolding",
]

@ -0,0 +1,72 @@
"""
Error log model for storing API errors
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, Float, Boolean
from sqlalchemy.sql import func
from app.core.database import Base
class ErrorLog(Base):
"""Model for storing API error logs"""
__tablename__ = "error_logs"
id = Column(Integer, primary_key=True, index=True)
# Request information
request_id = Column(String(50), index=True)
endpoint = Column(String(200), index=True)
method = Column(String(10))
path = Column(String(500))
query_params = Column(JSON, nullable=True)
request_body = Column(JSON, nullable=True)
headers = Column(JSON, nullable=True)
# Error information
error_type = Column(String(100), index=True)
error_message = Column(Text)
error_detail = Column(JSON, nullable=True)
status_code = Column(Integer, index=True)
stack_trace = Column(Text, nullable=True)
# Context information
user_agent = Column(String(500), nullable=True)
client_ip = Column(String(50), nullable=True)
session_id = Column(String(100), nullable=True)
user_id = Column(String(100), nullable=True)
# Performance metrics
response_time_ms = Column(Float, nullable=True)
# Resolution
is_resolved = Column(Boolean, default=False, index=True)
resolved_at = Column(DateTime(timezone=True), nullable=True)
resolution_notes = Column(Text, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
def to_dict(self):
"""Convert to dictionary for API responses"""
return {
"id": self.id,
"request_id": self.request_id,
"endpoint": self.endpoint,
"method": self.method,
"path": self.path,
"query_params": self.query_params,
"request_body": self.request_body,
"error_type": self.error_type,
"error_message": self.error_message,
"error_detail": self.error_detail,
"status_code": self.status_code,
"stack_trace": self.stack_trace,
"user_agent": self.user_agent,
"client_ip": self.client_ip,
"response_time_ms": self.response_time_ms,
"is_resolved": self.is_resolved,
"resolved_at": self.resolved_at.isoformat() if self.resolved_at else None,
"resolution_notes": self.resolution_notes,
"created_at": self.created_at.isoformat() if self.created_at else None
}

@ -0,0 +1,94 @@
"""
ETF models: mapping tables and persisted ETF holdings snapshots
"""
from sqlalchemy import Column, String, DateTime, Index, UniqueConstraint, Float, ForeignKey, JSON
from sqlalchemy.sql import func
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP
from datetime import datetime, timezone
import uuid
from app.core.database import Base
class CusipMap(Base):
__tablename__ = "cusip_map"
# CUSIP is 9-character identifier; some sources may include shorter variants
cusip = Column(String(20), primary_key=True, index=True)
symbol = Column(String(32), index=True)
description = Column(String, nullable=True)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
__table_args__ = (
Index("idx_cusip_symbol", "symbol"),
)
class ETFCIKMap(Base):
__tablename__ = "etf_cik_map"
# Store uppercased ticker symbols and numeric CIK (string)
ticker = Column(String(16), primary_key=True, index=True)
cik = Column(String(20), index=True)
name = Column(String, nullable=True)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
__table_args__ = (
Index("idx_etf_cik", "cik"),
UniqueConstraint("ticker", name="uq_etf_ticker"),
)
class ETFSeriesMap(Base):
__tablename__ = "etf_series_map"
ticker = Column(String(16), primary_key=True, index=True)
series_id = Column(String(32), index=True)
class_id = Column(String(32), nullable=True)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint("ticker", name="uq_etf_series_ticker"),
Index("idx_etf_series_series_id", "series_id"),
)
class ETFHoldingsSnapshot(Base):
__tablename__ = "etf_holdings_snapshot"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
ticker = Column(String(16), nullable=False, index=True)
snapshot_date = Column(TIMESTAMP(timezone=True), nullable=False)
source = Column(String(20), nullable=True) # 'SCRAPER' or 'SEC'
cik = Column(String(20), nullable=True)
filing_accession = Column(String(64), nullable=True)
xml_url = Column(String, nullable=True)
metadata_json = Column(JSON, nullable=True)
created_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
UniqueConstraint("ticker", "snapshot_date", name="uq_etf_snapshot"),
Index("idx_etf_snapshot_ticker_date", "ticker", "snapshot_date"),
)
class ETFHolding(Base):
__tablename__ = "etf_holdings"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
snapshot_id = Column(UUID(as_uuid=True), ForeignKey("etf_holdings_snapshot.id"), index=True, nullable=False)
name = Column(String, nullable=True)
cusip = Column(String(20), index=True, nullable=True)
ticker = Column(String(32), index=True, nullable=True)
shares = Column(Float, nullable=True)
value = Column(Float, nullable=True)
percentage = Column(Float, nullable=True)
created_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc))
__table_args__ = (
Index("idx_etf_holding_snapshot", "snapshot_id"),
Index("idx_etf_holding_cusip", "cusip"),
Index("idx_etf_holding_ticker", "ticker"),
)

@ -0,0 +1,175 @@
"""
Database models for SEC financial data
"""
from sqlalchemy import Column, String, Float, DateTime, Integer, JSON, Boolean, UniqueConstraint, Index
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP
from datetime import datetime, timezone
import uuid
from app.core.database import Base
class Company(Base):
__tablename__ = "companies"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
ticker = Column(String(10), unique=True, nullable=False, index=True)
name = Column(String(255), nullable=False)
cik = Column(String(20), unique=True, nullable=True)
sector = Column(String(100), nullable=True)
industry = Column(String(100), nullable=True)
business_description = Column(String, nullable=True)
created_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
Index('idx_company_ticker', 'ticker'),
Index('idx_company_cik', 'cik'),
)
class FinancialData(Base):
__tablename__ = "financial_data"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
ticker = Column(String(10), nullable=False, index=True)
period_date = Column(TIMESTAMP(timezone=True), nullable=False)
period_type = Column(String(10), nullable=False) # 'quarterly' or 'annual'
filing_type = Column(String(10), nullable=True) # '10-K', '10-Q', etc.
# Income Statement
revenue = Column(Float, nullable=True)
gross_profit = Column(Float, nullable=True)
operating_income = Column(Float, nullable=True)
net_income = Column(Float, nullable=True)
eps = Column(Float, nullable=True)
# Balance Sheet
total_assets = Column(Float, nullable=True)
total_equity = Column(Float, nullable=True)
total_debt = Column(Float, nullable=True)
cash = Column(Float, nullable=True)
shares_outstanding = Column(Float, nullable=True)
# Cash Flow
operating_cash_flow = Column(Float, nullable=True)
free_cash_flow = Column(Float, nullable=True)
capex = Column(Float, nullable=True)
# Raw data storage (for additional fields)
raw_data = Column(JSON, nullable=True)
# Metadata
data_source = Column(String(50), default='SEC_EDGAR')
is_estimated = Column(Boolean, default=False)
created_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
UniqueConstraint('ticker', 'period_date', 'period_type', name='uq_financial_data'),
Index('idx_financial_ticker_date', 'ticker', 'period_date'),
Index('idx_financial_period', 'period_date', 'period_type'),
)
class CalculatedMetrics(Base):
__tablename__ = "calculated_metrics"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
ticker = Column(String(10), nullable=False, index=True)
calculation_date = Column(TIMESTAMP(timezone=True), nullable=False)
period_date = Column(TIMESTAMP(timezone=True), nullable=False) # The financial data period this is based on
# Valuation Ratios (require price data)
pe_ratio = Column(Float, nullable=True)
pb_ratio = Column(Float, nullable=True)
ps_ratio = Column(Float, nullable=True)
ev_ebitda = Column(Float, nullable=True)
# Profitability Metrics
roe = Column(Float, nullable=True) # Return on Equity %
roa = Column(Float, nullable=True) # Return on Assets %
gross_margin = Column(Float, nullable=True) # %
operating_margin = Column(Float, nullable=True) # %
net_margin = Column(Float, nullable=True) # %
# Growth Metrics
revenue_growth_yoy = Column(Float, nullable=True) # %
revenue_growth_qoq = Column(Float, nullable=True) # %
eps_growth_yoy = Column(Float, nullable=True) # %
eps_growth_qoq = Column(Float, nullable=True) # %
# Liquidity & Solvency
debt_to_equity = Column(Float, nullable=True)
debt_to_assets = Column(Float, nullable=True)
current_ratio = Column(Float, nullable=True)
quick_ratio = Column(Float, nullable=True)
# Efficiency
asset_turnover = Column(Float, nullable=True)
inventory_turnover = Column(Float, nullable=True)
# Cash Flow Metrics
ocf_margin = Column(Float, nullable=True) # Operating Cash Flow Margin %
fcf_margin = Column(Float, nullable=True) # Free Cash Flow Margin %
fcf_yield = Column(Float, nullable=True) # %
# Market Metrics (when price data available)
market_cap = Column(Float, nullable=True)
enterprise_value = Column(Float, nullable=True)
# Additional metrics stored as JSON
additional_metrics = Column(JSON, nullable=True)
# Metadata
created_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
UniqueConstraint('ticker', 'calculation_date', 'period_date', name='uq_calculated_metrics'),
Index('idx_metrics_ticker_date', 'ticker', 'calculation_date'),
)
class PriceData(Base):
__tablename__ = "price_data"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
ticker = Column(String(10), nullable=False, index=True)
date = Column(TIMESTAMP(timezone=True), nullable=False)
open = Column(Float, nullable=True)
high = Column(Float, nullable=True)
low = Column(Float, nullable=True)
close = Column(Float, nullable=False)
volume = Column(Float, nullable=True)
adjusted_close = Column(Float, nullable=True)
# Additional price metrics
market_cap = Column(Float, nullable=True)
pe_ratio = Column(Float, nullable=True)
dividend_yield = Column(Float, nullable=True)
# Metadata
data_source = Column(String(50), default='MOCK') # Will be updated when real data source is added
created_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
UniqueConstraint('ticker', 'date', name='uq_price_data'),
Index('idx_price_ticker_date', 'ticker', 'date'),
)
class DataUpdateLog(Base):
__tablename__ = "data_update_logs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
ticker = Column(String(10), nullable=False, index=True)
update_type = Column(String(50), nullable=False) # 'financial', 'metrics', 'price'
start_date = Column(TIMESTAMP(timezone=True), nullable=True)
end_date = Column(TIMESTAMP(timezone=True), nullable=True)
status = Column(String(20), nullable=False) # 'pending', 'processing', 'completed', 'failed'
error_message = Column(String, nullable=True)
records_processed = Column(Integer, default=0)
started_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc))
completed_at = Column(TIMESTAMP(timezone=True), nullable=True)
__table_args__ = (
Index('idx_update_log_ticker', 'ticker'),
Index('idx_update_log_status', 'status'),
)

@ -0,0 +1,108 @@
"""
FRED (Federal Reserve Economic Data) database models
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, Float, Index, Boolean
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.sql import func
from app.core.database import Base
class FredSeries(Base):
"""FRED 시리즈 메타데이터 저장"""
__tablename__ = "fred_series"
id = Column(String(50), primary_key=True) # Series ID (e.g., "GDP")
title = Column(Text, nullable=False)
units = Column(String(100))
units_short = Column(String(50))
frequency = Column(String(20)) # Annual, Quarterly, Monthly, etc.
frequency_short = Column(String(10))
seasonal_adjustment = Column(String(50))
seasonal_adjustment_short = Column(String(10))
last_updated = Column(DateTime)
popularity = Column(Integer, default=0)
group_popularity = Column(Integer, default=0)
notes = Column(Text)
# Cache metadata
cached_at = Column(DateTime, default=func.now())
cache_expires_at = Column(DateTime)
# API response metadata
fred_metadata = Column(JSONB, nullable=True) # Store full FRED response
__table_args__ = (
Index('idx_fred_series_title', 'title'),
Index('idx_fred_series_frequency', 'frequency'),
Index('idx_fred_series_cached_at', 'cached_at'),
)
class FredObservation(Base):
"""FRED 시리즈 관측값 저장"""
__tablename__ = "fred_observations"
id = Column(Integer, primary_key=True, autoincrement=True)
series_id = Column(String(50), nullable=False, index=True)
date = Column(String(20), nullable=False) # YYYY-MM-DD format
value = Column(String(20)) # String to handle "." for missing values
realtime_start = Column(String(20))
realtime_end = Column(String(20))
# Cache metadata
cached_at = Column(DateTime, default=func.now())
__table_args__ = (
Index('idx_fred_obs_series_date', 'series_id', 'date'),
Index('idx_fred_obs_cached_at', 'cached_at'),
)
class FredApiUsage(Base):
"""FRED API 사용량 추적 (일일 1000개 제한 관리)"""
__tablename__ = "fred_api_usage"
id = Column(Integer, primary_key=True, autoincrement=True)
date = Column(String(10), nullable=False, index=True) # YYYY-MM-DD
endpoint = Column(String(50), nullable=False) # 'series', 'observations', etc.
series_id = Column(String(50))
request_params = Column(JSONB) # Store request parameters
success = Column(Boolean, default=True)
response_size = Column(Integer) # Number of records returned
created_at = Column(DateTime, default=func.now())
__table_args__ = (
Index('idx_fred_usage_date', 'date'),
Index('idx_fred_usage_endpoint', 'endpoint'),
Index('idx_fred_usage_date_endpoint', 'date', 'endpoint'),
)
class FredCacheStats(Base):
"""FRED 캐시 통계 및 성능 메트릭"""
__tablename__ = "fred_cache_stats"
id = Column(Integer, primary_key=True, autoincrement=True)
date = Column(String(10), nullable=False, index=True) # YYYY-MM-DD
# API usage stats
total_requests = Column(Integer, default=0)
api_calls_made = Column(Integer, default=0)
cache_hits = Column(Integer, default=0)
cache_hit_rate = Column(Float) # Percentage
# Data stats
total_series_cached = Column(Integer, default=0)
total_observations_cached = Column(Integer, default=0)
cache_size_mb = Column(Float)
# Performance stats
avg_response_time_ms = Column(Float)
api_limit_usage_pct = Column(Float) # Percentage of 1000 daily limit used
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
__table_args__ = (
Index('idx_fred_stats_date', 'date'),
)

@ -0,0 +1,59 @@
"""
Request log model for storing all API requests
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, Float, Boolean
from sqlalchemy.sql import func
from app.core.database import Base
class RequestLog(Base):
"""Model for storing API request logs"""
__tablename__ = "request_logs"
id = Column(Integer, primary_key=True, index=True)
# Request information
request_id = Column(String(50), index=True)
endpoint = Column(String(200), index=True)
method = Column(String(10))
path = Column(String(500))
query_params = Column(JSON, nullable=True)
request_body = Column(JSON, nullable=True)
headers = Column(JSON, nullable=True)
# Response information
status_code = Column(Integer, index=True)
response_size = Column(Integer, nullable=True)
# Context information
user_agent = Column(String(500), nullable=True)
client_ip = Column(String(50), nullable=True)
session_id = Column(String(100), nullable=True)
user_id = Column(String(100), nullable=True)
# Performance metrics
response_time_ms = Column(Float, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
def to_dict(self):
"""Convert to dictionary for API responses"""
return {
"id": self.id,
"request_id": self.request_id,
"endpoint": self.endpoint,
"method": self.method,
"path": self.path,
"query_params": self.query_params,
"request_body": self.request_body,
"headers": self.headers,
"status_code": self.status_code,
"response_size": self.response_size,
"user_agent": self.user_agent,
"client_ip": self.client_ip,
"response_time_ms": self.response_time_ms,
"created_at": self.created_at.isoformat() if self.created_at else None
}

@ -0,0 +1,33 @@
from app.schemas.financial import (
PeriodType,
DataSource,
ErrorType,
FinancialDataRequest,
CompanyInfo,
FinancialDataPoint,
CalculatedMetricsData,
FinancialDataResponse,
ErrorResponse,
DataCatalogItem,
DataCatalogResponse,
HealthCheckResponse,
MigrationRequest,
MigrationResponse
)
__all__ = [
"PeriodType",
"DataSource",
"ErrorType",
"FinancialDataRequest",
"CompanyInfo",
"FinancialDataPoint",
"CalculatedMetricsData",
"FinancialDataResponse",
"ErrorResponse",
"DataCatalogItem",
"DataCatalogResponse",
"HealthCheckResponse",
"MigrationRequest",
"MigrationResponse"
]

@ -0,0 +1,80 @@
"""
Pydantic schemas for error logs
"""
from datetime import datetime
from typing import Optional, Dict, List, Any
from pydantic import BaseModel, Field
class ErrorLogBase(BaseModel):
"""Base schema for error logs"""
request_id: str
endpoint: str
method: str
path: str
query_params: Optional[Dict[str, Any]] = None
request_body: Optional[Dict[str, Any]] = None
error_type: str
error_message: str
error_detail: Optional[Dict[str, Any]] = None
status_code: int
stack_trace: Optional[str] = None
user_agent: Optional[str] = None
client_ip: Optional[str] = None
response_time_ms: Optional[float] = None
class ErrorLogResponse(ErrorLogBase):
"""Response schema for error log"""
id: int
headers: Optional[Dict[str, str]] = None
is_resolved: bool = False
resolved_at: Optional[str] = None
resolution_notes: Optional[str] = None
created_at: str
class Config:
from_attributes = True
class ErrorLogListResponse(BaseModel):
"""Response schema for error log list"""
items: List[ErrorLogResponse]
total: int
page: int
page_size: int
total_pages: int
class ErrorLogStats(BaseModel):
"""Statistics about error logs"""
total_errors: int
resolved_errors: int
unresolved_errors: int
resolution_rate: float
errors_by_type: Dict[str, int]
errors_by_status_code: Dict[str, int]
errors_by_endpoint: Dict[str, int]
average_response_time_ms: float
hourly_trend: Dict[str, int]
start_date: str
end_date: str
class ErrorLogUpdate(BaseModel):
"""Schema for updating error log"""
is_resolved: Optional[bool] = None
resolution_notes: Optional[str] = None
class ErrorLogFilter(BaseModel):
"""Schema for filtering error logs"""
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
error_type: Optional[str] = None
status_code: Optional[int] = None
endpoint: Optional[str] = None
is_resolved: Optional[bool] = None
page: int = Field(1, ge=1)
page_size: int = Field(50, ge=1, le=200)

@ -0,0 +1,507 @@
"""
Pydantic schemas for API requests and responses
"""
from datetime import datetime, date
from typing import Optional, Dict, List, Any
from pydantic import BaseModel, Field, ConfigDict, validator
from enum import Enum
import uuid
import re
from .validators import (
validate_period_field,
validate_quarters_field,
validate_time_approaches,
validate_end_date_field
)
class PeriodType(str, Enum):
QUARTERLY = "quarterly"
ANNUAL = "annual"
ALL = "all"
class DataSource(str, Enum):
SEC_EDGAR = "SEC_EDGAR"
YAHOO_FINANCE = "YAHOO_FINANCE"
ALPHA_VANTAGE = "ALPHA_VANTAGE"
MOCK = "MOCK"
class ErrorType(str, Enum):
PARSING_ERROR = "PARSING_ERROR"
DATA_NOT_FOUND = "DATA_NOT_FOUND"
INVALID_PERIOD = "INVALID_PERIOD"
SEC_API_ERROR = "SEC_API_ERROR"
DATABASE_ERROR = "DATABASE_ERROR"
VALIDATION_ERROR = "VALIDATION_ERROR"
AUTHENTICATION_ERROR = "AUTHENTICATION_ERROR"
RATE_LIMIT_ERROR = "RATE_LIMIT_ERROR"
# Request Schemas
class FinancialDataRequest(BaseModel):
"""
Request for financial data with flexible time period specification.
**Three ways to specify time period (choose one):**
1. **Date Range**: Use start_date and end_date
2. **Quarters**: Use quarters list (e.g., ['2024Q1', '2024Q2'])
3. **Period**: Use period string (e.g., '1d', '3m', '2y')
**Important**: Cannot mix approaches in the same request.
"""
ticker: str = Field(..., min_length=1, max_length=10, description="Stock ticker symbol")
# Date range approach
start_date: Optional[date] = Field(None, description="Start date for data retrieval. Cannot be used with quarters or period.")
end_date: Optional[date] = Field(None, description="End date for data retrieval. Cannot be used with quarters or period.")
# Quarter-based approach
quarters: Optional[List[str]] = Field(
None,
min_items=1,
max_items=40,
description="List of quarters in format 'YYYYQN' (e.g., ['2020Q1', '2020Q2']). Cannot be used with start_date/end_date or period. If provided, dates are ignored."
)
# Period-based approach
period: Optional[str] = Field(
None,
description="Period string like '1d', '7d', '1m', '3m', '1y', '2y'. Cannot be used with start_date/end_date or quarters."
)
period_type: PeriodType = Field(PeriodType.ALL, description="Type of financial periods to retrieve")
include_metrics: bool = Field(True, description="Include calculated metrics in response")
force_refresh: bool = Field(False, description="Force refresh data from SEC")
@validator('period')
def validate_period(cls, v):
return validate_period_field(cls, v)
@validator('quarters')
def validate_quarters(cls, v):
return validate_quarters_field(cls, v)
@validator('start_date')
def validate_time_approaches(cls, v, values):
return validate_time_approaches(cls, v, values)
@validator('end_date')
def validate_end_date(cls, v, values):
return validate_end_date_field(cls, v, values)
class BulkFinancialDataRequest(BaseModel):
tickers: List[str] = Field(..., min_items=1, max_items=500, description="List of stock ticker symbols (max 500 for efficient bulk processing)")
# Three approaches: use either date range, quarters, or period (not mix)
start_date: Optional[date] = Field(None, description="Start date for data retrieval. Cannot be used with quarters or period.")
end_date: Optional[date] = Field(None, description="End date for data retrieval. Cannot be used with quarters or period.")
# Quarter-based approach
quarters: Optional[List[str]] = Field(
None,
min_items=1,
max_items=40,
description="List of quarters in format 'YYYYQN' (e.g., ['2020Q1', '2020Q2']). Cannot be used with start_date/end_date or period. If provided, dates are ignored."
)
# Period-based approach
period: Optional[str] = Field(
None,
description="Period string like '1d', '7d', '1m', '3m', '1y', '2y'. Cannot be used with start_date/end_date or quarters."
)
period_type: PeriodType = Field(PeriodType.ALL, description="Type of financial periods to retrieve")
include_metrics: bool = Field(True, description="Include calculated metrics in response")
force_refresh: bool = Field(False, description="Force refresh data from SEC")
@validator('quarters')
def validate_quarters(cls, v):
"""Validate quarter format"""
if v:
for quarter in v:
if not re.match(r'^\d{4}Q[1-4]$', quarter):
raise ValueError(f"Invalid quarter format: {quarter}. Expected format: YYYYQN (e.g., 2020Q1)")
return v
@validator('start_date')
def validate_dates_or_quarters(cls, v, values):
"""Ensure either dates or quarters are provided"""
quarters = values.get('quarters')
if not v and not quarters:
raise ValueError("Either start_date/end_date or quarters must be provided")
if v and quarters:
raise ValueError("Cannot specify both date range and quarters - use one or the other")
return v
@validator('end_date')
def validate_end_date(cls, v, values):
"""Validate end_date if using date-based approach"""
start_date = values.get('start_date')
quarters = values.get('quarters')
if not quarters: # Using date-based approach
if not v:
raise ValueError("end_date is required when not using quarters")
if start_date and v <= start_date:
raise ValueError("end_date must be after start_date")
return v
class PriceDataRequest(BaseModel):
"""
Request for price data with flexible time period specification.
**Three ways to specify time period (choose one):**
1. **Date Range**: Use start_date and end_date
2. **Quarters**: Use quarters list (e.g., ['2024Q1', '2024Q2'])
3. **Period**: Use period string (e.g., '1d', '3m', '2y')
**Important**: Cannot mix approaches in the same request.
"""
ticker: str = Field(..., min_length=1, max_length=10, description="Stock ticker symbol")
# Date range approach
start_date: Optional[date] = Field(None, description="Start date for data retrieval. Cannot be used with quarters or period.")
end_date: Optional[date] = Field(None, description="End date for data retrieval. Cannot be used with quarters or period.")
# Quarter-based approach
quarters: Optional[List[str]] = Field(
None,
min_items=1,
max_items=40,
description="List of quarters in format 'YYYYQN' (e.g., ['2020Q1', '2020Q2']). Cannot be used with start_date/end_date or period. If provided, dates are ignored."
)
# Period-based approach
period: Optional[str] = Field(
None,
description="Period string like '1d', '7d', '1m', '3m', '1y', '2y'. Cannot be used with start_date/end_date or quarters."
)
interval: str = Field("1d", description="Data interval: 1d, 1w, 1m, 5d, 1h, etc.")
force_refresh: bool = Field(False, description="Force refresh data from Yahoo Finance")
@validator('interval')
def validate_interval(cls, v):
"""Validate interval format"""
valid_intervals = ['1m', '2m', '5m', '15m', '30m', '60m', '90m', '1h', '1d', '5d', '1w', '1mo', '3mo']
if v not in valid_intervals:
raise ValueError(f"Invalid interval: {v}. Valid intervals: {', '.join(valid_intervals)}")
return v
@validator('quarters')
def validate_quarters(cls, v):
"""Validate quarter format"""
if v:
for quarter in v:
if not re.match(r'^\d{4}Q[1-4]$', quarter):
raise ValueError(f"Invalid quarter format: {quarter}. Expected format: YYYYQN (e.g., 2020Q1)")
return v
@validator('start_date')
def validate_dates_or_quarters(cls, v, values):
"""Ensure either dates or quarters are provided"""
quarters = values.get('quarters')
if not v and not quarters:
raise ValueError("Either start_date/end_date or quarters must be provided")
if v and quarters:
raise ValueError("Cannot specify both date range and quarters - use one or the other")
return v
@validator('end_date')
def validate_end_date(cls, v, values):
"""Validate end_date if using date-based approach"""
start_date = values.get('start_date')
quarters = values.get('quarters')
if not quarters: # Using date-based approach
if not v:
raise ValueError("end_date is required when not using quarters")
if start_date and v <= start_date:
raise ValueError("end_date must be after start_date")
return v
class BulkPriceDataRequest(BaseModel):
tickers: List[str] = Field(..., min_items=1, max_items=500, description="List of stock ticker symbols (max 500 for efficient bulk processing)")
# Three approaches: use either date range, quarters, or period (not mix)
start_date: Optional[date] = Field(None, description="Start date for data retrieval. Cannot be used with quarters or period.")
end_date: Optional[date] = Field(None, description="End date for data retrieval. Cannot be used with quarters or period.")
# Quarter-based approach
quarters: Optional[List[str]] = Field(
None,
min_items=1,
max_items=40,
description="List of quarters in format 'YYYYQN' (e.g., ['2020Q1', '2020Q2']). Cannot be used with start_date/end_date or period. If provided, dates are ignored."
)
# Period-based approach
period: Optional[str] = Field(
None,
description="Period string like '1d', '7d', '1m', '3m', '1y', '2y'. Cannot be used with start_date/end_date or quarters."
)
interval: str = Field("1d", description="Data interval: 1d, 1w, 1m, 5d, 1h, etc.")
force_refresh: bool = Field(False, description="Force refresh data from Yahoo Finance")
@validator('interval')
def validate_interval(cls, v):
"""Validate interval format"""
valid_intervals = ['1m', '2m', '5m', '15m', '30m', '60m', '90m', '1h', '1d', '5d', '1w', '1mo', '3mo']
if v not in valid_intervals:
raise ValueError(f"Invalid interval: {v}. Valid intervals: {', '.join(valid_intervals)}")
return v
@validator('quarters')
def validate_quarters(cls, v):
"""Validate quarter format"""
if v:
for quarter in v:
if not re.match(r'^\d{4}Q[1-4]$', quarter):
raise ValueError(f"Invalid quarter format: {quarter}. Expected format: YYYYQN (e.g., 2020Q1)")
return v
@validator('start_date')
def validate_dates_or_quarters(cls, v, values):
"""Ensure either dates or quarters are provided"""
quarters = values.get('quarters')
if not v and not quarters:
raise ValueError("Either start_date/end_date or quarters must be provided")
if v and quarters:
raise ValueError("Cannot specify both date range and quarters - use one or the other")
return v
@validator('end_date')
def validate_end_date(cls, v, values):
"""Validate end_date if using date-based approach"""
start_date = values.get('start_date')
quarters = values.get('quarters')
if not quarters: # Using date-based approach
if not v:
raise ValueError("end_date is required when not using quarters")
if start_date and v <= start_date:
raise ValueError("end_date must be after start_date")
return v
# Response Schemas
class CompanyInfo(BaseModel):
model_config = ConfigDict(from_attributes=True)
ticker: str
name: str
cik: Optional[str] = None
sector: Optional[str] = None
industry: Optional[str] = None
business_description: Optional[str] = None
class FinancialDataPoint(BaseModel):
model_config = ConfigDict(from_attributes=True)
period_date: datetime
period_type: str
filing_type: Optional[str] = None
# Income Statement
revenue: Optional[float] = None
gross_profit: Optional[float] = None
operating_income: Optional[float] = None
net_income: Optional[float] = None
eps: Optional[float] = None
# Balance Sheet
total_assets: Optional[float] = None
total_equity: Optional[float] = None
total_debt: Optional[float] = None
cash: Optional[float] = None
shares_outstanding: Optional[float] = None
# Cash Flow
operating_cash_flow: Optional[float] = None
free_cash_flow: Optional[float] = None
capex: Optional[float] = None
# Calculated Metrics (계산된 지표들)
pe_ratio: Optional[float] = None
pb_ratio: Optional[float] = None
ps_ratio: Optional[float] = None
roe: Optional[float] = None
roa: Optional[float] = None
gross_margin: Optional[float] = None
operating_margin: Optional[float] = None
net_margin: Optional[float] = None
debt_to_equity: Optional[float] = None
debt_to_assets: Optional[float] = None
ocf_margin: Optional[float] = None
fcf_margin: Optional[float] = None
market_cap: Optional[float] = None
# Metadata
data_source: str
is_estimated: bool
class CalculatedMetricsData(BaseModel):
model_config = ConfigDict(from_attributes=True)
period_date: datetime
calculation_date: datetime
# Valuation (실제로 계산 가능한 것만)
pe_ratio: Optional[float] = None
pb_ratio: Optional[float] = None
ps_ratio: Optional[float] = None
# Profitability
roe: Optional[float] = None
roa: Optional[float] = None
gross_margin: Optional[float] = None
operating_margin: Optional[float] = None
net_margin: Optional[float] = None
# Liquidity & Solvency
debt_to_equity: Optional[float] = None
debt_to_assets: Optional[float] = None
# Cash Flow
ocf_margin: Optional[float] = None
fcf_margin: Optional[float] = None
# Market
market_cap: Optional[float] = None
class PriceDataPoint(BaseModel):
model_config = ConfigDict(from_attributes=True)
date: date
open: Optional[float] = None
high: Optional[float] = None
low: Optional[float] = None
close: float
volume: Optional[float] = None
adjusted_close: Optional[float] = None
data_source: str
@validator('date', pre=True)
def convert_datetime_to_date(cls, v):
"""Convert datetime to date if needed"""
if isinstance(v, datetime):
return v.date()
return v
class FinancialDataResponse(BaseModel):
company: CompanyInfo
financial_data: List[FinancialDataPoint]
metadata: Dict[str, Any] = Field(default_factory=dict)
class BulkFinancialDataItem(BaseModel):
ticker: str
success: bool
data: Optional[FinancialDataResponse] = None
error: Optional[str] = None
class BulkFinancialDataResponse(BaseModel):
results: List[BulkFinancialDataItem]
metadata: Dict[str, Any] = Field(default_factory=dict)
class PriceDataResponse(BaseModel):
ticker: str
interval: str
data: List[PriceDataPoint]
metadata: Dict[str, Any] = Field(default_factory=dict)
class BulkPriceDataItem(BaseModel):
ticker: str
success: bool
data: Optional[PriceDataResponse] = None
error: Optional[str] = None
class BulkPriceDataResponse(BaseModel):
results: List[BulkPriceDataItem]
metadata: Dict[str, Any] = Field(default_factory=dict)
class ErrorResponse(BaseModel):
error_type: ErrorType
message: str
detail: Optional[Dict[str, Any]] = None
timestamp: datetime = Field(default_factory=datetime.utcnow)
# New schemas for quote/intraday/today endpoints
class QuoteResponse(BaseModel):
ticker: str
price: float
regular_price: Optional[float] = None
pre_market_price: Optional[float] = None
post_market_price: Optional[float] = None
currency: Optional[str] = None
exchange: Optional[str] = None
market_state: Optional[str] = None
timestamp: datetime
source: str = Field("YAHOO_FINANCE")
delayed: Optional[bool] = True
class IntradayCandle(BaseModel):
timestamp: datetime
open: Optional[float] = None
high: Optional[float] = None
low: Optional[float] = None
close: float
volume: Optional[float] = None
class IntradayResponse(BaseModel):
ticker: str
interval: str
period: str
candles: List[IntradayCandle]
metadata: Dict[str, Any] = Field(default_factory=dict)
class TodayOHLCResponse(BaseModel):
ticker: str
date: date
open: Optional[float] = None
high: Optional[float] = None
low: Optional[float] = None
close: float
volume: Optional[float] = None
source: str = Field("YAHOO_FINANCE")
method: str = Field("daily")
metadata: Dict[str, Any] = Field(default_factory=dict)
class DataCatalogItem(BaseModel):
field_name: str
description: str
data_type: str
unit: Optional[str] = None
calculation: Optional[str] = None
source: str
class DataCatalogResponse(BaseModel):
categories: Dict[str, List[DataCatalogItem]]
last_updated: datetime
class HealthCheckResponse(BaseModel):
status: str
version: str
database: str
cache: str
sec_data_available: bool
timestamp: datetime
class MigrationRequest(BaseModel):
source_url: str = Field(..., description="Source API URL to migrate from")
api_key: str = Field(..., description="API key for authentication")
tickers: Optional[List[str]] = Field(None, description="Specific tickers to migrate, or all if not specified")
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
class MigrationResponse(BaseModel):
status: str
total_records: int
migrated_records: int
failed_records: int
errors: List[Dict[str, Any]] = Field(default_factory=list)
duration_seconds: float

@ -0,0 +1,50 @@
"""
Request log schemas for API responses
"""
from datetime import datetime
from typing import Dict, List, Optional, Any
from pydantic import BaseModel
class RequestLogResponse(BaseModel):
"""Response schema for individual request log"""
id: int
request_id: str
endpoint: str
method: str
path: str
query_params: Optional[Dict[str, Any]] = None
request_body: Optional[Dict[str, Any]] = None
headers: Optional[Dict[str, Any]] = None
status_code: int
response_size: Optional[int] = None
user_agent: Optional[str] = None
client_ip: Optional[str] = None
response_time_ms: Optional[float] = None
created_at: Optional[str] = None
class RequestLogListResponse(BaseModel):
"""Response schema for paginated request logs"""
items: List[RequestLogResponse]
total: int
page: int
page_size: int
total_pages: int
class RequestLogStats(BaseModel):
"""Response schema for request log statistics"""
total_requests: int
success_requests: int # 2xx status codes
client_error_requests: int # 4xx status codes
server_error_requests: int # 5xx status codes
success_rate: float # percentage
requests_by_method: Dict[str, int]
requests_by_status_code: Dict[str, int]
requests_by_endpoint: Dict[str, int]
average_response_time_ms: float
hourly_trend: Dict[str, int] # Hour -> count
start_date: str
end_date: str

@ -0,0 +1,54 @@
"""
Additional validators for schema validation
"""
from pydantic import validator
import re
def validate_period_field(cls, v):
"""Validate period format"""
if v:
from app.utils.date_utils import validate_period_format
if not validate_period_format(v):
raise ValueError(f"Invalid period format: {v}. Expected format: Nd/Nm/Ny (e.g., 1d, 3m, 2y)")
return v
def validate_quarters_field(cls, v):
"""Validate quarter format"""
if v:
for quarter in v:
if not re.match(r'^\d{4}Q[1-4]$', quarter):
raise ValueError(f"Invalid quarter format: {quarter}. Expected format: YYYYQN (e.g., 2020Q1)")
return v
def validate_time_approaches(cls, v, values):
"""Ensure one of dates, quarters, or period is provided"""
quarters = values.get('quarters')
period = values.get('period')
# Count non-None approaches
approaches = [bool(v), bool(quarters), bool(period)]
provided_count = sum(approaches)
if provided_count == 0:
raise ValueError("One of start_date/end_date, quarters, or period must be provided")
if provided_count > 1:
raise ValueError("Cannot specify multiple time approaches - use one of: date range, quarters, or period")
return v
def validate_end_date_field(cls, v, values):
"""Validate end_date if using date-based approach"""
start_date = values.get('start_date')
quarters = values.get('quarters')
period = values.get('period')
if not quarters and not period: # Using date-based approach
if not v:
raise ValueError("end_date is required when using date range approach")
if start_date and v <= start_date:
raise ValueError("end_date must be after start_date")
return v

@ -0,0 +1,3 @@
from app.services.sec_data_service import SECDataService
__all__ = ["SECDataService"]

File diff suppressed because it is too large Load Diff

@ -0,0 +1,126 @@
"""
Service to load and refresh ETF mapping tables from remote CSV sources.
Sources:
- CUSIP mapping: https://raw.githubusercontent.com/yoshishima/Stock_Data/refs/heads/master/CUSIP.csv
- ETF CIK mapping: https://raw.githubusercontent.com/yoshishima/Stock_Data/refs/heads/master/SEC_CIKs_Symbols.csv
These files are used to create local mapping tables for fast lookups.
"""
from typing import Optional
import csv
import io
import asyncio
import aiohttp
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete
from app.models.etf import CusipMap, ETFCIKMap
CUSIP_CSV_URL = "https://raw.githubusercontent.com/yoshishima/Stock_Data/refs/heads/master/CUSIP.csv"
CIK_CSV_URL = "https://raw.githubusercontent.com/yoshishima/Stock_Data/refs/heads/master/SEC_CIKs_Symbols.csv"
class ETFLoaderService:
async def _fetch_text(self, url: str, timeout: int = 30) -> str:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session:
async with session.get(url) as resp:
resp.raise_for_status()
return await resp.text()
async def refresh_cusip_map(self, db: AsyncSession) -> int:
"""Fetch CUSIP CSV and refresh table.
Columns: cusip,symbol,description
Returns number of rows inserted.
"""
text = await self._fetch_text(CUSIP_CSV_URL)
reader = csv.DictReader(io.StringIO(text))
# Truncate existing and commit to avoid conflicts
await db.execute(delete(CusipMap))
await db.commit()
count = 0
to_add = []
seen = set()
for row in reader:
cusip = (row.get("cusip") or "").strip()
symbol = (row.get("symbol") or "").strip().upper()
description = row.get("description")
if not cusip or not symbol or cusip in seen:
continue
seen.add(cusip)
to_add.append(CusipMap(cusip=cusip, symbol=symbol, description=description))
count += 1
if to_add:
db.add_all(to_add)
await db.commit()
return count
async def refresh_etf_cik_map(self, db: AsyncSession) -> int:
"""Fetch SEC CIKs CSV and refresh ETF CIK map.
Expected columns include: Ticker, CIK or similar (we normalize).
Returns number of rows inserted.
"""
text = await self._fetch_text(CIK_CSV_URL)
reader = csv.DictReader(io.StringIO(text))
# Truncate existing and commit to avoid conflicts
await db.execute(delete(ETFCIKMap))
await db.commit()
count = 0
to_add = []
seen_ticker = set()
# Try to detect column names
headers = [h.lower() for h in reader.fieldnames or []]
ticker_key: Optional[str] = None
cik_key: Optional[str] = None
name_key: Optional[str] = None
for h in headers:
if h in ("ticker", "symbol"):
ticker_key = h
if h in ("cik", "ciknumber", "cik_num", "cik number"):
cik_key = h
if h in ("name", "companyname", "company name"):
name_key = h
# Fallback sensible defaults
if ticker_key is None:
ticker_key = "symbol" if "symbol" in headers else "ticker"
if cik_key is None:
cik_key = "cik"
if name_key is None:
name_key = "name" if "name" in headers else None
for row in reader:
ticker = (row.get(ticker_key) or "").strip().upper()
cik_raw = (row.get(cik_key) or "").strip()
if not ticker or not cik_raw or ticker in seen_ticker:
continue
seen_ticker.add(ticker)
# Normalize CIK to digits only (leading zeros removed)
digits = "".join(ch for ch in cik_raw if ch.isdigit())
if not digits:
continue
name = (row.get(name_key) or "").strip() if name_key else None
to_add.append(ETFCIKMap(ticker=ticker, cik=str(int(digits)), name=name))
count += 1
if to_add:
db.add_all(to_add)
await db.commit()
return count
async def refresh_all(self, db: AsyncSession) -> dict:
cusips = await self.refresh_cusip_map(db)
etfs = await self.refresh_etf_cik_map(db)
return {"cusip_rows": cusips, "etf_rows": etfs}
etf_loader_service = ETFLoaderService()

@ -0,0 +1,626 @@
"""
Real financial data service that combines price data with calculations
"""
from datetime import datetime, timezone, timedelta, date
from typing import Dict, List, Optional, Tuple, Union
import logging
import numpy as np
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_, desc
from app.models.financial import Company, FinancialData, CalculatedMetrics, PriceData
from app.schemas.financial import DataSource
from app.services.price_data_service import PriceDataService
from app.utils.date_utils import parse_period, quarters_to_date_range, resolve_time_parameters
from app.core.config import settings
logger = logging.getLogger(__name__)
class FinancialService:
"""Real financial service that uses actual price data for calculations"""
def __init__(self):
self.price_service = PriceDataService()
def _get_ticker_max_range(self, ticker: str) -> Tuple[datetime, datetime]:
"""
Get maximum date range for a ticker by checking its listing date via yfinance_plus
Args:
ticker: Stock ticker symbol
Returns:
Tuple of (listing_date, current_date) or fallback to 20 years if yfinance unavailable
"""
try:
# Try to get ticker info from yfinance_plus to find actual listing date
import yfinance_plus as yf
ticker_obj = yf.Ticker(ticker)
# Get a small sample of historical data to find the earliest available date
# Use period="max" and interval="1mo" for faster query
hist = ticker_obj.history(period="max", interval="1mo")
if not hist.empty:
# Get the earliest date from the historical data
earliest_date = hist.index[0].to_pydatetime()
if earliest_date.tzinfo is None:
earliest_date = earliest_date.replace(tzinfo=timezone.utc)
# Current date as end
end_date = datetime.now(timezone.utc).replace(hour=23, minute=59, second=59, microsecond=0)
# Ensure we don't go beyond SEC data availability (1994)
sec_start = datetime(settings.SEC_DATA_START_YEAR, 1, 1, tzinfo=timezone.utc)
actual_start = max(earliest_date, sec_start)
logger.info(f"Found actual listing date for {ticker}: {actual_start.date()}")
return actual_start, end_date
except Exception as e:
logger.warning(f"Could not get ticker info for {ticker}: {e}")
# Fallback to 20-year max if yfinance_plus fails
logger.info(f"Using fallback 20-year range for {ticker}")
end_date = datetime.now(timezone.utc).replace(hour=23, minute=59, second=59, microsecond=0)
start_date = end_date - timedelta(days=20 * 365.25) # 20 years
# Ensure we don't go beyond SEC data availability (1994)
sec_start = datetime(settings.SEC_DATA_START_YEAR, 1, 1, tzinfo=timezone.utc)
actual_start = max(start_date, sec_start)
return actual_start, end_date
async def get_or_create_company_data(
self,
db: AsyncSession,
ticker: str,
start_date: Optional[Union[date, datetime]] = None,
end_date: Optional[Union[date, datetime]] = None,
quarters: Optional[List[str]] = None,
period: Optional[str] = None,
force_refresh: bool = False
) -> Dict:
"""
Get company data with real price-based calculations
"""
ticker = ticker.upper()
# Resolve time parameters to standard datetime range
resolved_start, resolved_end = resolve_time_parameters(
start_date, end_date, quarters, period, ticker,
ticker_max_range_fn=self._get_ticker_max_range
)
# Get or create company
company = await self._get_or_create_company(db, ticker)
# Get financial data from database or generate realistic data
financial_data = await self._get_or_generate_financial_data(
db, ticker, resolved_start, resolved_end, force_refresh
)
# Get price data for calculations
price_data = await self._get_price_data_for_period(
db, ticker, resolved_start, resolved_end
)
# Calculate metrics using real price data
calculated_metrics = await self._calculate_real_metrics(
db, ticker, financial_data, price_data, force_refresh
)
return {
"company": company,
"financial_data": financial_data,
"calculated_metrics": calculated_metrics
}
async def _get_or_create_company(self, db: AsyncSession, ticker: str) -> Company:
"""Get or create company record"""
result = await db.execute(
select(Company).where(Company.ticker == ticker)
)
company = result.scalar_one_or_none()
if not company:
# Create company with basic info (in real implementation, this would fetch from SEC)
company_info = self._get_default_company_info(ticker)
company = Company(
ticker=ticker,
name=company_info["name"],
cik=company_info["cik"],
sector=company_info["sector"],
industry=company_info["industry"],
business_description=company_info["business_description"],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.add(company)
await db.commit()
await db.refresh(company)
return company
def _get_default_company_info(self, ticker: str) -> Dict:
"""Get default company info (placeholder for real SEC data)"""
company_defaults = {
'AAPL': {
'name': 'Apple Inc.',
'cik': '0000320193',
'sector': 'Technology',
'industry': 'Consumer Electronics',
'business_description': 'Technology company designing and manufacturing consumer electronics'
},
'MSFT': {
'name': 'Microsoft Corporation',
'cik': '0000789019',
'sector': 'Technology',
'industry': 'Software—Infrastructure',
'business_description': 'Software and cloud services company'
},
'TSLA': {
'name': 'Tesla Inc.',
'cik': '0001318605',
'sector': 'Consumer Cyclical',
'industry': 'Auto Manufacturers',
'business_description': 'Electric vehicle and clean energy company'
},
'NVDA': {
'name': 'NVIDIA Corporation',
'cik': '0001045810',
'sector': 'Technology',
'industry': 'Semiconductors',
'business_description': 'Semiconductor company specializing in graphics processing units'
}
}
return company_defaults.get(ticker, {
'name': f'{ticker} Corporation',
'cik': f'000{hash(ticker) % 1000000:06d}',
'sector': 'Technology',
'industry': 'Software',
'business_description': f'{ticker} technology company'
})
async def _get_or_generate_financial_data(
self,
db: AsyncSession,
ticker: str,
start_date: datetime,
end_date: datetime,
force_refresh: bool = False
) -> List[FinancialData]:
"""Get financial data from database or return empty list if no real data exists"""
# First, try to get real data from SEC EDGAR if not force refresh
if not force_refresh:
try:
from app.services.sec_edgar_service import SECEdgarService
sec_service = SECEdgarService()
real_financial_data = await sec_service.get_financial_data(
db, ticker, start_date, end_date, force_refresh
)
# If we got real data, return it
if real_financial_data:
logger.info(f"Found {len(real_financial_data)} real financial records for {ticker}")
return real_financial_data
else:
logger.info(f"No real financial data found for {ticker}, returning empty list")
return []
except Exception as e:
logger.error(f"Error fetching real financial data for {ticker}: {e}")
# Fall back to database check if SEC service fails
# Check existing data in database (both real and estimated)
result = await db.execute(
select(FinancialData)
.where(
and_(
FinancialData.ticker == ticker,
FinancialData.period_date >= start_date,
FinancialData.period_date <= end_date
)
)
.order_by(FinancialData.period_date)
)
existing_data = result.scalars().all()
if existing_data and not force_refresh:
return existing_data
# If no real data and no existing data, return empty list instead of generating estimated data
logger.info(f"No financial data available for {ticker} in the requested period")
return []
async def _generate_realistic_financial_data(
self,
db: AsyncSession,
ticker: str,
start_date: datetime,
end_date: datetime,
force_refresh: bool = False
) -> List[FinancialData]:
"""Generate realistic financial data based on company size and sector"""
# Get company base metrics from real market data if available
base_metrics = await self._estimate_company_size(db, ticker)
# Generate quarterly periods
quarters = self._generate_quarterly_periods(start_date, end_date)
financial_records = []
for i, quarter_end in enumerate(quarters):
# Calculate growth factor based on time progression
years_from_start = (quarter_end - start_date).days / 365.25
growth_factor = (1.05) ** years_from_start # 5% annual growth baseline
# Add some realistic volatility
volatility = np.random.normal(1.0, 0.08) # 8% volatility
total_factor = growth_factor * volatility
# Generate realistic financial metrics
revenue = base_metrics['revenue'] * total_factor
gross_profit = revenue * base_metrics['gross_margin']
operating_income = revenue * base_metrics['operating_margin']
net_income = revenue * base_metrics['net_margin']
# Balance sheet items
total_assets = base_metrics['total_assets'] * total_factor
total_equity = total_assets * base_metrics['equity_ratio']
total_debt = total_assets * base_metrics['debt_ratio']
cash = total_assets * base_metrics['cash_ratio']
shares_outstanding = base_metrics['shares_outstanding']
# Cash flow items
operating_cash_flow = net_income * 1.15 # OCF typically higher than net income
capex = revenue * 0.04 # 4% of revenue
free_cash_flow = operating_cash_flow - capex
eps = net_income / shares_outstanding if shares_outstanding > 0 else 0
# Check if record exists
existing = await db.execute(
select(FinancialData).where(
and_(
FinancialData.ticker == ticker,
FinancialData.period_date == quarter_end,
FinancialData.period_type == "quarterly"
)
)
)
if existing.scalar_one_or_none() and not force_refresh:
continue
# Create or update financial data record
financial_record = FinancialData(
ticker=ticker,
period_date=quarter_end,
period_type="quarterly",
filing_type="10-Q",
revenue=revenue,
gross_profit=gross_profit,
operating_income=operating_income,
net_income=net_income,
eps=eps,
total_assets=total_assets,
total_equity=total_equity,
total_debt=total_debt,
cash=cash,
shares_outstanding=shares_outstanding,
operating_cash_flow=operating_cash_flow,
free_cash_flow=free_cash_flow,
capex=capex,
data_source=DataSource.SEC_EDGAR.value,
is_estimated=True, # Mark as estimated since we're generating it
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Delete existing if force refresh
if force_refresh:
await db.execute(
select(FinancialData).where(
and_(
FinancialData.ticker == ticker,
FinancialData.period_date == quarter_end,
FinancialData.period_type == "quarterly"
)
)
)
db.add(financial_record)
financial_records.append(financial_record)
await db.commit()
# Refresh all records to get IDs
for record in financial_records:
await db.refresh(record)
return financial_records
async def _estimate_company_size(self, db: AsyncSession, ticker: str) -> Dict:
"""Estimate company size based on recent price data and industry"""
# Get recent price data to estimate market cap
recent_date = datetime.now(timezone.utc) - timedelta(days=30)
result = await db.execute(
select(PriceData)
.where(
and_(
PriceData.ticker == ticker,
PriceData.date >= recent_date
)
)
.order_by(desc(PriceData.date))
.limit(1)
)
recent_price = result.scalar_one_or_none()
# Default metrics based on typical companies
default_metrics = {
'revenue': 50_000_000_000, # $50B
'total_assets': 75_000_000_000, # $75B
'shares_outstanding': 1_000_000_000, # 1B shares
'gross_margin': 0.45, # 45%
'operating_margin': 0.15, # 15%
'net_margin': 0.12, # 12%
'equity_ratio': 0.40, # 40%
'debt_ratio': 0.25, # 25%
'cash_ratio': 0.10, # 10%
}
if recent_price:
# Estimate company size based on current price
estimated_market_cap = recent_price.close * default_metrics['shares_outstanding']
# Adjust metrics based on estimated market cap
if estimated_market_cap > 1_000_000_000_000: # $1T+ (mega cap)
scale_factor = 5.0
elif estimated_market_cap > 200_000_000_000: # $200B+ (large cap)
scale_factor = 3.0
elif estimated_market_cap > 10_000_000_000: # $10B+ (mid cap)
scale_factor = 1.5
else: # Small cap
scale_factor = 0.5
default_metrics['revenue'] *= scale_factor
default_metrics['total_assets'] *= scale_factor
return default_metrics
def _generate_quarterly_periods(self, start_date: datetime, end_date: datetime) -> List[datetime]:
"""Generate quarterly period end dates"""
quarters = []
# Start from the first quarter end after start_date
current_year = start_date.year
quarter_ends = [
datetime(current_year, 3, 31, tzinfo=timezone.utc),
datetime(current_year, 6, 30, tzinfo=timezone.utc),
datetime(current_year, 9, 30, tzinfo=timezone.utc),
datetime(current_year, 12, 31, tzinfo=timezone.utc),
]
# Find first quarter end >= start_date
for qe in quarter_ends:
if qe >= start_date:
quarters.append(qe)
# Add subsequent years
year = current_year + 1
while True:
year_quarters = [
datetime(year, 3, 31, tzinfo=timezone.utc),
datetime(year, 6, 30, tzinfo=timezone.utc),
datetime(year, 9, 30, tzinfo=timezone.utc),
datetime(year, 12, 31, tzinfo=timezone.utc),
]
added_any = False
for qe in year_quarters:
if qe <= end_date:
quarters.append(qe)
added_any = True
else:
break
if not added_any:
break
year += 1
return quarters
async def _get_price_data_for_period(
self,
db: AsyncSession,
ticker: str,
start_date: datetime,
end_date: datetime
) -> List[PriceData]:
"""Get price data for the specified period"""
# Try to get from database first
result = await db.execute(
select(PriceData)
.where(
and_(
PriceData.ticker == ticker,
PriceData.date >= start_date,
PriceData.date <= end_date
)
)
.order_by(PriceData.date)
)
price_data = result.scalars().all()
# If no price data, try to fetch it
if not price_data:
try:
price_data = await self.price_service.get_or_update_price_data(
db, ticker, start_date, end_date, "1d", force_refresh=False
)
except Exception as e:
logger.warning(f"Could not fetch price data for {ticker}: {e}")
price_data = []
return price_data
async def _calculate_real_metrics(
self,
db: AsyncSession,
ticker: str,
financial_data: List[FinancialData],
price_data: List[PriceData],
force_refresh: bool = False
) -> List[CalculatedMetrics]:
"""Calculate metrics using real price data"""
calculated_metrics = []
for financial_record in financial_data:
period_date = financial_record.period_date
# Check if metrics already exist
existing_metrics = None
if not force_refresh:
existing = await db.execute(
select(CalculatedMetrics).where(
and_(
CalculatedMetrics.ticker == ticker,
CalculatedMetrics.period_date == period_date
)
)
)
existing_metrics = existing.scalar_one_or_none()
if existing_metrics:
calculated_metrics.append(existing_metrics)
continue
# Find price data close to the period date
price_at_period = self._find_price_near_date(price_data, period_date)
if not price_at_period:
logger.warning(f"No price data found for {ticker} near {period_date}")
continue
# Calculate valuation metrics using real price
market_cap = price_at_period.close * financial_record.shares_outstanding if financial_record.shares_outstanding else None
pe_ratio = None
if financial_record.eps and financial_record.eps > 0:
pe_ratio = price_at_period.close / financial_record.eps
pb_ratio = None
if financial_record.total_equity and financial_record.shares_outstanding:
book_value_per_share = financial_record.total_equity / financial_record.shares_outstanding
if book_value_per_share > 0:
pb_ratio = price_at_period.close / book_value_per_share
ps_ratio = None
if financial_record.revenue and financial_record.shares_outstanding:
revenue_per_share = financial_record.revenue / financial_record.shares_outstanding
if revenue_per_share > 0:
ps_ratio = price_at_period.close / revenue_per_share
# Calculate profitability metrics
roe = None
if financial_record.net_income and financial_record.total_equity and financial_record.total_equity > 0:
roe = financial_record.net_income / financial_record.total_equity
roa = None
if financial_record.net_income and financial_record.total_assets and financial_record.total_assets > 0:
roa = financial_record.net_income / financial_record.total_assets
gross_margin = None
if financial_record.gross_profit and financial_record.revenue and financial_record.revenue > 0:
gross_margin = financial_record.gross_profit / financial_record.revenue
operating_margin = None
if financial_record.operating_income and financial_record.revenue and financial_record.revenue > 0:
operating_margin = financial_record.operating_income / financial_record.revenue
net_margin = None
if financial_record.net_income and financial_record.revenue and financial_record.revenue > 0:
net_margin = financial_record.net_income / financial_record.revenue
# Calculate debt ratios
debt_to_equity = None
if financial_record.total_debt and financial_record.total_equity and financial_record.total_equity > 0:
debt_to_equity = financial_record.total_debt / financial_record.total_equity
debt_to_assets = None
if financial_record.total_debt and financial_record.total_assets and financial_record.total_assets > 0:
debt_to_assets = financial_record.total_debt / financial_record.total_assets
# Calculate cash flow metrics
ocf_margin = None
if financial_record.operating_cash_flow and financial_record.revenue and financial_record.revenue > 0:
ocf_margin = financial_record.operating_cash_flow / financial_record.revenue
fcf_margin = None
if financial_record.free_cash_flow and financial_record.revenue and financial_record.revenue > 0:
fcf_margin = financial_record.free_cash_flow / financial_record.revenue
# Create calculated metrics record
metrics = CalculatedMetrics(
ticker=ticker,
calculation_date=datetime.now(timezone.utc),
period_date=period_date,
pe_ratio=pe_ratio,
pb_ratio=pb_ratio,
ps_ratio=ps_ratio,
roe=roe,
roa=roa,
gross_margin=gross_margin,
operating_margin=operating_margin,
net_margin=net_margin,
debt_to_equity=debt_to_equity,
debt_to_assets=debt_to_assets,
ocf_margin=ocf_margin,
fcf_margin=fcf_margin,
market_cap=market_cap,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.add(metrics)
calculated_metrics.append(metrics)
if calculated_metrics:
await db.commit()
for metrics in calculated_metrics:
await db.refresh(metrics)
return calculated_metrics
def _find_price_near_date(self, price_data: List[PriceData], target_date: datetime) -> Optional[PriceData]:
"""Find price data closest to the target date"""
if not price_data:
return None
# Convert target_date to date for comparison
target_date_only = target_date.date()
closest_price = None
min_diff = float('inf')
for price in price_data:
price_date = price.date.date() if hasattr(price.date, 'date') else price.date
diff = abs((price_date - target_date_only).days)
if diff < min_diff:
min_diff = diff
closest_price = price
return closest_price

@ -0,0 +1,788 @@
"""
FRED API Pass-through Proxy Service
모든 FRED API 엔드포인트를 proxy로 전달하는 서비스
"""
import logging
import httpx
import json
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, desc, text
from app.models.fred_data import FredApiUsage, FredSeries, FredObservation
from app.core.config import settings
logger = logging.getLogger(__name__)
class FredProxyService:
"""FRED API Proxy Service with intelligent caching and daily limit management"""
def __init__(self):
self.api_key = "2b12c4c62a7e9d9002d746dad7bfd147"
self.base_url = "https://api.stlouisfed.org/fred"
self.daily_limit = 1000
self.cache_duration_hours = 24 # 24시간 캐시
async def _check_daily_limit(self, db: AsyncSession) -> Tuple[bool, int, int]:
"""
일일 API 사용량 확인
Returns:
(can_make_request, used_today, remaining)
"""
today = datetime.now().strftime('%Y-%m-%d')
# 오늘의 API 사용량 조회
result = await db.execute(
select(func.count(FredApiUsage.id))
.where(and_(
FredApiUsage.date == today,
FredApiUsage.success == True
))
)
used_today = result.scalar() or 0
remaining = self.daily_limit - used_today
can_make_request = remaining > 0
logger.debug(f"📊 FRED API usage today: {used_today}/{self.daily_limit} (remaining: {remaining})")
return can_make_request, used_today, remaining
async def _log_api_usage(
self,
db: AsyncSession,
endpoint: str,
request_params: Optional[Dict] = None,
success: bool = True,
response_size: int = 0,
series_id: Optional[str] = None
):
"""API 사용량 로깅"""
today = datetime.now().strftime('%Y-%m-%d')
# series_id 추출 시도
if not series_id and request_params:
series_id = request_params.get('series_id')
usage_log = FredApiUsage(
date=today,
endpoint=endpoint,
series_id=series_id,
request_params=request_params or {},
success=success,
response_size=response_size
)
db.add(usage_log)
await db.commit()
logger.info(f"📝 FRED API call logged: {endpoint} {'✅' if success else '❌'}")
async def _is_cache_valid(self, cached_at: datetime, cache_hours: int = 24) -> bool:
"""캐시 유효성 확인"""
if not cached_at:
return False
expiry_time = cached_at + timedelta(hours=cache_hours)
return datetime.now() < expiry_time
async def _cache_series_data(self, db: AsyncSession, series_data: Dict) -> None:
"""시리즈 데이터를 DB에 캐시"""
try:
series_id = series_data['id']
# 기존 데이터 조회
cached_series = await db.execute(
select(FredSeries).where(FredSeries.id == series_id)
)
existing = cached_series.scalar_one_or_none()
if existing:
# 업데이트
existing.title = series_data.get('title')
existing.units = series_data.get('units')
existing.units_short = series_data.get('units_short')
existing.frequency = series_data.get('frequency')
existing.frequency_short = series_data.get('frequency_short')
existing.seasonal_adjustment = series_data.get('seasonal_adjustment')
existing.seasonal_adjustment_short = series_data.get('seasonal_adjustment_short')
existing.last_updated = datetime.fromisoformat(series_data['last_updated'].replace('-05', '')) if series_data.get('last_updated') else None
existing.popularity = series_data.get('popularity', 0)
existing.notes = series_data.get('notes')
existing.cached_at = datetime.now()
existing.cache_expires_at = datetime.now() + timedelta(hours=self.cache_duration_hours)
existing.fred_metadata = series_data
else:
# 새로 생성
new_series = FredSeries(
id=series_data['id'],
title=series_data.get('title'),
units=series_data.get('units'),
units_short=series_data.get('units_short'),
frequency=series_data.get('frequency'),
frequency_short=series_data.get('frequency_short'),
seasonal_adjustment=series_data.get('seasonal_adjustment'),
seasonal_adjustment_short=series_data.get('seasonal_adjustment_short'),
last_updated=datetime.fromisoformat(series_data['last_updated'].replace('-05', '')) if series_data.get('last_updated') else None,
popularity=series_data.get('popularity', 0),
notes=series_data.get('notes'),
cached_at=datetime.now(),
cache_expires_at=datetime.now() + timedelta(hours=self.cache_duration_hours),
fred_metadata=series_data
)
db.add(new_series)
await db.commit()
logger.info(f"✅ FRED series cached: {series_id} - {series_data.get('title', 'Unknown')[:50]}")
except Exception as e:
logger.error(f"❌ Error caching series data: {e}")
async def _cache_observations_data(self, db: AsyncSession, series_id: str, observations: list) -> int:
"""관측값 데이터를 DB에 영구 저장 (중복 방지)"""
try:
# 기존 데이터 조회
existing_dates = set()
existing_query = await db.execute(
select(FredObservation.date).where(FredObservation.series_id == series_id)
)
existing_dates = {row[0] for row in existing_query.fetchall()}
# 새로운 관측값만 저장
new_observations_count = 0
for obs_data in observations:
obs_date = obs_data['date']
if obs_date not in existing_dates:
new_obs = FredObservation(
series_id=series_id,
date=obs_date,
value=obs_data['value'],
realtime_start=obs_data.get('realtime_start'),
realtime_end=obs_data.get('realtime_end'),
cached_at=datetime.now()
)
db.add(new_obs)
new_observations_count += 1
else:
# 기존 데이터의 cached_at 업데이트 (최신성 표시)
update_query = await db.execute(
select(FredObservation).where(
and_(
FredObservation.series_id == series_id,
FredObservation.date == obs_date
)
)
)
existing_obs = update_query.scalar_one_or_none()
if existing_obs:
existing_obs.cached_at = datetime.now()
await db.commit()
total_in_db = len(existing_dates) + new_observations_count
logger.info(f"✅ FRED observations cached: {series_id} ({new_observations_count} new, {total_in_db} total in DB)")
return new_observations_count
except Exception as e:
logger.error(f"❌ Error caching observations data: {e}")
return 0
async def _get_cached_series(self, db: AsyncSession, series_id: str) -> Optional[Dict]:
"""캐시된 시리즈 데이터 조회"""
try:
cached_series = await db.execute(
select(FredSeries).where(FredSeries.id == series_id)
)
cached = cached_series.scalar_one_or_none()
if cached and await self._is_cache_valid(cached.cached_at):
logger.info(f"📦 FRED series cache hit: {series_id}")
return {
'id': cached.id,
'title': cached.title,
'units': cached.units,
'frequency': cached.frequency,
'last_updated': cached.last_updated.isoformat() if cached.last_updated else None,
'cached': True,
'cached_at': cached.cached_at.isoformat(),
'fred_metadata': cached.fred_metadata
}
return None
except Exception as e:
logger.error(f"❌ Error getting cached series: {e}")
return None
async def _get_cached_observations(
self,
db: AsyncSession,
series_id: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: Optional[int] = None
) -> Optional[Dict]:
"""캐시된 관측값 데이터 조회"""
try:
query = select(FredObservation).where(FredObservation.series_id == series_id)
if start_date:
query = query.where(FredObservation.date >= start_date)
if end_date:
query = query.where(FredObservation.date <= end_date)
query = query.order_by(desc(FredObservation.date))
if limit:
query = query.limit(limit)
cached_obs = await db.execute(query)
cached_data = cached_obs.scalars().all()
# 캐시가 있고 최근 데이터인지 확인
if cached_data and await self._is_cache_valid(cached_data[0].cached_at):
logger.info(f"📦 FRED observations cache hit: {series_id} ({len(cached_data)} records)")
return {
'series_id': series_id,
'observations': [
{
'date': obs.date,
'value': obs.value,
'realtime_start': obs.realtime_start,
'realtime_end': obs.realtime_end
}
for obs in cached_data
],
'count': len(cached_data),
'cached': True,
'cached_at': cached_data[0].cached_at.isoformat() if cached_data else None
}
return None
except Exception as e:
logger.error(f"❌ Error getting cached observations: {e}")
return None
async def _make_fred_request(self, endpoint: str, params: Dict) -> Optional[Dict]:
"""FRED API 요청 실행"""
url = f"{self.base_url}/{endpoint}"
params['api_key'] = self.api_key
params['file_type'] = 'json'
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, params=params)
response.raise_for_status()
data = response.json()
# 응답 크기 추정
response_size = 0
if 'seriess' in data:
response_size = len(data.get('seriess', []))
elif 'observations' in data:
response_size = len(data.get('observations', []))
elif 'categories' in data:
response_size = len(data.get('categories', []))
elif 'sources' in data:
response_size = len(data.get('sources', []))
elif 'releases' in data:
response_size = len(data.get('releases', []))
elif 'tags' in data:
response_size = len(data.get('tags', []))
else:
response_size = 1
logger.info(f"✅ FRED API success: {endpoint} -> {response_size} records")
return {
'data': data,
'response_size': response_size
}
except httpx.HTTPError as e:
logger.error(f"❌ FRED API error: {endpoint} -> {e}")
return None
except Exception as e:
logger.error(f"❌ FRED API unexpected error: {endpoint} -> {e}")
return None
async def _handle_multiple_series_observations(
self,
db: AsyncSession,
series_ids: list,
params: Dict[str, Any],
bypass_limit_check: bool = False,
force_refresh: bool = False
) -> Dict[str, Any]:
"""다중 시리즈의 관측값을 개별 요청으로 처리"""
try:
all_observations = []
all_metadata = []
successful_series = []
failed_series = []
# 각 시리즈를 개별적으로 요청
for series_id in series_ids:
individual_params = params.copy()
individual_params['series_id'] = series_id.strip()
try:
result = await self.proxy_fred_request(
db,
'series/observations',
individual_params,
bypass_limit_check,
force_refresh
)
if result['success'] and 'data' in result and 'observations' in result['data']:
# 각 관측값에 series_id 추가
for obs in result['data']['observations']:
obs['series_id'] = series_id.strip()
all_observations.extend(result['data']['observations'])
all_metadata.append({
'series_id': series_id.strip(),
'count': len(result['data']['observations']),
'cached': result['metadata'].get('cached', False)
})
successful_series.append(series_id.strip())
else:
failed_series.append({
'series_id': series_id.strip(),
'error': result.get('error', 'Unknown error')
})
except Exception as e:
logger.error(f"❌ Error processing series {series_id}: {e}")
failed_series.append({
'series_id': series_id.strip(),
'error': str(e)
})
# 결과 정렬 (날짜순)
all_observations.sort(key=lambda x: x['date'])
# API 사용량 로깅 (다중 시리즈)
await self._log_api_usage(
db,
'series/observations',
params,
len(successful_series) > 0,
len(all_observations),
','.join(successful_series)
)
return {
'success': len(successful_series) > 0,
'data': {
'realtime_start': datetime.now().strftime('%Y-%m-%d'),
'realtime_end': datetime.now().strftime('%Y-%m-%d'),
'observation_start': params.get('observation_start', '1600-01-01'),
'observation_end': params.get('observation_end', '9999-12-31'),
'units': 'lin',
'output_type': 1,
'file_type': 'json',
'order_by': 'observation_date',
'sort_order': 'asc',
'count': len(all_observations),
'offset': 0,
'limit': params.get('limit', 100000),
'observations': all_observations
},
'metadata': {
'source': 'fred.stlouisfed.org',
'endpoint': 'series/observations',
'proxy_mode': True,
'multiple_series': True,
'successful_series': successful_series,
'failed_series': failed_series,
'series_metadata': all_metadata,
'total_series_requested': len(series_ids),
'successful_series_count': len(successful_series),
'failed_series_count': len(failed_series)
}
}
except Exception as e:
logger.error(f"❌ Error in multiple series observations handler: {e}")
return {
'success': False,
'error': f'Internal server error: {str(e)}',
'details': {
'endpoint': 'series/observations',
'series_ids': series_ids,
'params': params
}
}
async def proxy_fred_request(
self,
db: AsyncSession,
endpoint: str,
params: Dict[str, Any],
bypass_limit_check: bool = False,
force_refresh: bool = False
) -> Dict[str, Any]:
"""
FRED API 요청을 proxy로 전달 (캐싱 및 영구 저장 지원)
Args:
db: Database session
endpoint: FRED API endpoint (e.g., "series", "series/observations")
params: Query parameters
bypass_limit_check: 제한 확인 우회 (관리자용)
force_refresh: 캐시 무시하고 API 호출
Returns:
API response with metadata
"""
try:
series_id = params.get('series_id')
# 다중 시리즈 요청 처리 (series/observations 엔드포인트만)
if endpoint == 'series/observations' and series_id and ',' in series_id:
series_ids = [s.strip() for s in series_id.split(',')]
logger.info(f"🔄 Processing multiple series observations: {len(series_ids)} series")
return await self._handle_multiple_series_observations(
db, series_ids, params, bypass_limit_check, force_refresh
)
# 1. 캐시 확인 (force_refresh가 아닌 경우)
if not force_refresh and series_id:
if endpoint == 'series':
# 시리즈 정보 캐시 확인
cached_data = await self._get_cached_series(db, series_id)
if cached_data:
return {
'success': True,
'data': {'seriess': [cached_data['fred_metadata']]},
'metadata': {
'source': 'fred.stlouisfed.org',
'endpoint': endpoint,
'proxy_mode': True,
'cached': True,
'cached_at': cached_data['cached_at']
}
}
elif endpoint == 'series/observations':
# 관측값 캐시 확인
start_date = params.get('observation_start')
end_date = params.get('observation_end')
limit = params.get('limit')
if isinstance(limit, str):
limit = int(limit)
cached_data = await self._get_cached_observations(db, series_id, start_date, end_date, limit)
if cached_data:
return {
'success': True,
'data': {
'realtime_start': datetime.now().strftime('%Y-%m-%d'),
'realtime_end': datetime.now().strftime('%Y-%m-%d'),
'observation_start': start_date or '1600-01-01',
'observation_end': end_date or '9999-12-31',
'units': 'lin',
'output_type': 1,
'file_type': 'json',
'order_by': 'observation_date',
'sort_order': 'desc',
'count': cached_data['count'],
'offset': 0,
'limit': limit or 1000000,
'observations': cached_data['observations']
},
'metadata': {
'source': 'fred.stlouisfed.org',
'endpoint': endpoint,
'proxy_mode': True,
'cached': True,
'cached_at': cached_data['cached_at']
}
}
# 2. API 호출 가능 여부 확인 (bypass_limit_check가 False인 경우)
if not bypass_limit_check:
can_call, used, remaining = await self._check_daily_limit(db)
if not can_call:
logger.warning(f"🚫 FRED API daily limit reached: {used}/{self.daily_limit}")
# 캐시된 데이터라도 반환 (만료되었더라도)
if series_id:
if endpoint == 'series':
cached_data = await self._get_cached_series(db, series_id)
if cached_data:
cached_data['cache_expired'] = True
cached_data['api_limit_reached'] = True
return {
'success': True,
'data': {'seriess': [cached_data['fred_metadata']]},
'metadata': {
'source': 'fred.stlouisfed.org',
'endpoint': endpoint,
'proxy_mode': True,
'cached': True,
'cache_expired': True,
'api_limit_reached': True
}
}
elif endpoint == 'series/observations':
start_date = params.get('observation_start')
end_date = params.get('observation_end')
limit = params.get('limit')
if isinstance(limit, str):
limit = int(limit)
cached_data = await self._get_cached_observations(db, series_id, start_date, end_date, limit)
if cached_data:
return {
'success': True,
'data': {
'realtime_start': datetime.now().strftime('%Y-%m-%d'),
'realtime_end': datetime.now().strftime('%Y-%m-%d'),
'observation_start': start_date or '1600-01-01',
'observation_end': end_date or '9999-12-31',
'units': 'lin',
'output_type': 1,
'file_type': 'json',
'order_by': 'observation_date',
'sort_order': 'desc',
'count': cached_data['count'],
'offset': 0,
'limit': limit or 1000000,
'observations': cached_data['observations']
},
'metadata': {
'source': 'fred.stlouisfed.org',
'endpoint': endpoint,
'proxy_mode': True,
'cached': True,
'cache_expired': True,
'api_limit_reached': True
}
}
return {
'success': False,
'error': 'Daily API limit reached',
'details': {
'used_today': used,
'daily_limit': self.daily_limit,
'remaining': remaining
}
}
else:
can_call, used, remaining = True, 0, self.daily_limit
# 3. FRED API 호출
response_data = await self._make_fred_request(endpoint, params)
if not response_data:
await self._log_api_usage(db, endpoint, params, False, 0)
return {
'success': False,
'error': 'Failed to fetch data from FRED API',
'details': {
'endpoint': endpoint,
'params': params
}
}
# 4. 응답 처리 및 캐싱
data = response_data['data']
response_size = response_data['response_size']
# 캐싱 로직
if series_id:
if endpoint == 'series' and 'seriess' in data and data['seriess']:
# 시리즈 데이터 캐싱
series_data = data['seriess'][0]
await self._cache_series_data(db, series_data)
elif endpoint == 'series/observations' and 'observations' in data:
# 관측값 데이터 캐싱 (영구 저장)
observations = data['observations']
new_count = await self._cache_observations_data(db, series_id, observations)
logger.info(f"📦 Cached {new_count} new observations for {series_id}")
await self._log_api_usage(db, endpoint, params, True, response_size, series_id)
logger.info(f"✅ FRED proxy success: {endpoint} -> {response_size} records")
return {
'success': True,
'data': data,
'metadata': {
'source': 'fred.stlouisfed.org',
'endpoint': endpoint,
'proxy_mode': True,
'api_calls_remaining': remaining - 1 if can_call else remaining,
'response_size': response_size,
'cached': False
}
}
except Exception as e:
logger.error(f"❌ Error in FRED proxy request: {e}")
return {
'success': False,
'error': f'Internal server error: {str(e)}',
'details': {
'endpoint': endpoint,
'params': params
}
}
async def get_api_usage_stats(self, db: AsyncSession, days: int = 7) -> Dict:
"""API 사용량 통계 조회"""
try:
end_date = datetime.now()
start_date = end_date - timedelta(days=days)
# 기간별 사용량 조회 - 간단한 방식으로 변경
usage_query = await db.execute(
select(
FredApiUsage.date,
func.count(FredApiUsage.id).label('total_calls'),
func.count(FredApiUsage.id).filter(FredApiUsage.success == True).label('successful_calls'),
func.sum(FredApiUsage.response_size).label('total_records')
)
.where(FredApiUsage.date >= start_date.strftime('%Y-%m-%d'))
.group_by(FredApiUsage.date)
.order_by(desc(FredApiUsage.date))
)
daily_stats = []
for row in usage_query.fetchall():
successful_calls = row.successful_calls or 0
total_calls = row.total_calls or 0
daily_stats.append({
'date': row.date,
'total_calls': total_calls,
'successful_calls': successful_calls,
'total_records': row.total_records or 0,
'success_rate': (successful_calls / total_calls * 100) if total_calls > 0 else 0
})
# 오늘의 사용량
today = datetime.now().strftime('%Y-%m-%d')
can_call, used_today, remaining = await self._check_daily_limit(db)
# 캐시 통계
cache_stats_query = await db.execute(
select(
func.count(FredSeries.id).label('cached_series'),
func.count(FredObservation.id).label('cached_observations')
)
)
cache_row = cache_stats_query.fetchone()
# 엔드포인트별 사용량 (상위 10개)
endpoint_query = await db.execute(
select(
FredApiUsage.endpoint,
func.count(FredApiUsage.id).label('call_count')
)
.where(FredApiUsage.date >= start_date.strftime('%Y-%m-%d'))
.group_by(FredApiUsage.endpoint)
.order_by(desc(func.count(FredApiUsage.id)))
.limit(10)
)
endpoint_stats = [
{
'endpoint': row.endpoint,
'call_count': row.call_count
}
for row in endpoint_query.fetchall()
]
return {
'success': True,
'data': {
'daily_limit': self.daily_limit,
'used_today': used_today,
'remaining_today': remaining,
'usage_percentage': (used_today / self.daily_limit * 100),
'can_make_requests': can_call,
'daily_stats': daily_stats,
'endpoint_stats': endpoint_stats,
'cache_stats': {
'cached_series': cache_row.cached_series or 0,
'cached_observations': cache_row.cached_observations or 0,
'cache_duration_hours': self.cache_duration_hours
},
'proxy_info': {
'mode': 'pass_through_proxy',
'cache_duration_hours': self.cache_duration_hours,
'supported_endpoints': 'all_fred_endpoints',
'permanent_storage': True,
'smart_caching': True
}
}
}
except Exception as e:
logger.error(f"❌ Error getting API usage stats: {e}")
return {'success': False, 'error': str(e)}
def get_supported_endpoints(self) -> Dict[str, Any]:
"""지원되는 FRED API 엔드포인트 목록 반환"""
return {
'series_endpoints': [
'series',
'series/categories',
'series/observations',
'series/release',
'series/search',
'series/search/tags',
'series/search/related_tags',
'series/tags',
'series/updates',
'series/vintagedates'
],
'category_endpoints': [
'category',
'category/children',
'category/related',
'category/series',
'category/tags',
'category/related_tags'
],
'release_endpoints': [
'releases',
'releases/dates',
'release',
'release/dates',
'release/series',
'release/sources',
'release/tags',
'release/related_tags',
'release/tables'
],
'source_endpoints': [
'sources',
'source',
'source/releases'
],
'tag_endpoints': [
'tags',
'related_tags',
'tags/series'
],
'other_endpoints': [
'search',
'search/tags'
],
'note': 'All FRED API endpoints are supported through pass-through proxy'
}
# 싱글톤 인스턴스
fred_proxy_service = FredProxyService()

@ -0,0 +1,441 @@
"""
FRED (Federal Reserve Economic Data) Service
FRED API 호출, 캐싱, 일일 1000개 제한 관리
"""
import logging
import httpx
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, desc
from app.models.fred_data import FredSeries, FredObservation, FredApiUsage, FredCacheStats
from app.core.config import settings
logger = logging.getLogger(__name__)
class FredService:
"""FRED API 서비스 with intelligent caching and daily limit management"""
def __init__(self):
self.api_key = "2b12c4c62a7e9d9002d746dad7bfd147"
self.base_url = "https://api.stlouisfed.org/fred"
self.daily_limit = 1000
self.cache_duration_hours = 24 # 24시간 캐시
async def _check_daily_limit(self, db: AsyncSession) -> Tuple[bool, int, int]:
"""
일일 API 사용량 확인
Returns:
(can_make_request, used_today, remaining)
"""
today = datetime.now().strftime('%Y-%m-%d')
# 오늘의 API 사용량 조회
result = await db.execute(
select(func.count(FredApiUsage.id))
.where(and_(
FredApiUsage.date == today,
FredApiUsage.success == True
))
)
used_today = result.scalar() or 0
remaining = self.daily_limit - used_today
can_make_request = remaining > 0
logger.debug(f"📊 FRED API usage today: {used_today}/{self.daily_limit} (remaining: {remaining})")
return can_make_request, used_today, remaining
async def _log_api_usage(
self,
db: AsyncSession,
endpoint: str,
series_id: Optional[str] = None,
request_params: Optional[Dict] = None,
success: bool = True,
response_size: int = 0
):
"""API 사용량 로깅"""
today = datetime.now().strftime('%Y-%m-%d')
usage_log = FredApiUsage(
date=today,
endpoint=endpoint,
series_id=series_id,
request_params=request_params or {},
success=success,
response_size=response_size
)
db.add(usage_log)
await db.commit()
logger.info(f"📝 FRED API call logged: {endpoint} {'✅' if success else '❌'}")
async def _is_cache_valid(self, cached_at: datetime, cache_hours: int = 24) -> bool:
"""캐시 유효성 확인"""
if not cached_at:
return False
expiry_time = cached_at + timedelta(hours=cache_hours)
return datetime.now() < expiry_time
async def _make_fred_request(self, endpoint: str, params: Dict) -> Optional[Dict]:
"""FRED API 요청 실행"""
url = f"{self.base_url}/{endpoint}"
params['api_key'] = self.api_key
params['file_type'] = 'json'
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, params=params)
response.raise_for_status()
data = response.json()
logger.info(f"✅ FRED API success: {endpoint} -> {len(data.get('seriess', data.get('observations', [])))} records")
return data
except httpx.HTTPError as e:
logger.error(f"❌ FRED API error: {endpoint} -> {e}")
return None
except Exception as e:
logger.error(f"❌ FRED API unexpected error: {endpoint} -> {e}")
return None
async def get_series_info(self, db: AsyncSession, series_id: str, force_refresh: bool = False) -> Optional[Dict]:
"""
FRED 시리즈 정보 조회 (캐시 우선)
Args:
db: Database session
series_id: FRED series ID (e.g., "GDP")
force_refresh: 캐시 무시하고 API 호출
Returns:
Series information dict or None
"""
try:
# 1. 캐시에서 조회 (force_refresh가 아닌 경우)
if not force_refresh:
cached_series = await db.execute(
select(FredSeries).where(FredSeries.id == series_id)
)
cached = cached_series.scalar_one_or_none()
if cached and await self._is_cache_valid(cached.cached_at):
logger.info(f"📦 FRED series cache hit: {series_id}")
return {
'id': cached.id,
'title': cached.title,
'units': cached.units,
'frequency': cached.frequency,
'last_updated': cached.last_updated.isoformat() if cached.last_updated else None,
'cached': True,
'cached_at': cached.cached_at.isoformat()
}
# 2. API 호출 가능 여부 확인
can_call, used, remaining = await self._check_daily_limit(db)
if not can_call:
logger.warning(f"🚫 FRED API daily limit reached: {used}/{self.daily_limit}")
# 캐시된 데이터라도 반환
if not force_refresh:
cached_series = await db.execute(
select(FredSeries).where(FredSeries.id == series_id)
)
cached = cached_series.scalar_one_or_none()
if cached:
return {
'id': cached.id,
'title': cached.title,
'units': cached.units,
'frequency': cached.frequency,
'last_updated': cached.last_updated.isoformat() if cached.last_updated else None,
'cached': True,
'cache_expired': True,
'api_limit_reached': True
}
return None
# 3. FRED API 호출
params = {'series_id': series_id}
response_data = await self._make_fred_request('series', params)
if not response_data or 'seriess' not in response_data:
await self._log_api_usage(db, 'series', series_id, params, False, 0)
return None
# 4. 응답 처리 및 캐시 저장
series_data = response_data['seriess'][0] if response_data['seriess'] else None
if not series_data:
await self._log_api_usage(db, 'series', series_id, params, False, 0)
return None
# 5. 데이터베이스에 저장/업데이트
cached_series = await db.execute(
select(FredSeries).where(FredSeries.id == series_id)
)
existing = cached_series.scalar_one_or_none()
if existing:
# 업데이트
existing.title = series_data.get('title')
existing.units = series_data.get('units')
existing.units_short = series_data.get('units_short')
existing.frequency = series_data.get('frequency')
existing.frequency_short = series_data.get('frequency_short')
existing.seasonal_adjustment = series_data.get('seasonal_adjustment')
existing.seasonal_adjustment_short = series_data.get('seasonal_adjustment_short')
existing.last_updated = datetime.fromisoformat(series_data['last_updated'].replace('-05', '')) if series_data.get('last_updated') else None
existing.popularity = series_data.get('popularity', 0)
existing.notes = series_data.get('notes')
existing.cached_at = datetime.now()
existing.cache_expires_at = datetime.now() + timedelta(hours=self.cache_duration_hours)
existing.fred_metadata = series_data
else:
# 새로 생성
new_series = FredSeries(
id=series_data['id'],
title=series_data.get('title'),
units=series_data.get('units'),
units_short=series_data.get('units_short'),
frequency=series_data.get('frequency'),
frequency_short=series_data.get('frequency_short'),
seasonal_adjustment=series_data.get('seasonal_adjustment'),
seasonal_adjustment_short=series_data.get('seasonal_adjustment_short'),
last_updated=datetime.fromisoformat(series_data['last_updated'].replace('-05', '')) if series_data.get('last_updated') else None,
popularity=series_data.get('popularity', 0),
notes=series_data.get('notes'),
cached_at=datetime.now(),
cache_expires_at=datetime.now() + timedelta(hours=self.cache_duration_hours),
fred_metadata=series_data
)
db.add(new_series)
await db.commit()
await self._log_api_usage(db, 'series', series_id, params, True, 1)
logger.info(f"✅ FRED series cached: {series_id} - {series_data.get('title', 'Unknown')[:50]}")
return {
'id': series_data['id'],
'title': series_data.get('title'),
'units': series_data.get('units'),
'frequency': series_data.get('frequency'),
'last_updated': series_data.get('last_updated'),
'cached': False,
'api_calls_remaining': remaining - 1
}
except Exception as e:
logger.error(f"❌ Error in get_series_info: {e}")
return None
async def get_series_observations(
self,
db: AsyncSession,
series_id: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: Optional[int] = None,
force_refresh: bool = False
) -> Optional[Dict]:
"""
FRED 시리즈 관측값 조회 (캐시 우선)
Args:
db: Database session
series_id: FRED series ID
start_date: YYYY-MM-DD format
end_date: YYYY-MM-DD format
limit: 최대 반환 개수
force_refresh: 캐시 무시하고 API 호출
Returns:
Observations data dict or None
"""
try:
# 1. 캐시에서 조회 (force_refresh가 아닌 경우)
if not force_refresh:
query = select(FredObservation).where(FredObservation.series_id == series_id)
if start_date:
query = query.where(FredObservation.date >= start_date)
if end_date:
query = query.where(FredObservation.date <= end_date)
query = query.order_by(desc(FredObservation.date))
if limit:
query = query.limit(limit)
cached_obs = await db.execute(query)
cached_data = cached_obs.scalars().all()
# 캐시가 있고 최근 데이터인지 확인
if cached_data and await self._is_cache_valid(cached_data[0].cached_at):
logger.info(f"📦 FRED observations cache hit: {series_id} ({len(cached_data)} records)")
return {
'series_id': series_id,
'observations': [
{
'date': obs.date,
'value': obs.value,
'realtime_start': obs.realtime_start,
'realtime_end': obs.realtime_end
}
for obs in cached_data
],
'count': len(cached_data),
'cached': True,
'cached_at': cached_data[0].cached_at.isoformat() if cached_data else None
}
# 2. API 호출 가능 여부 확인
can_call, used, remaining = await self._check_daily_limit(db)
if not can_call:
logger.warning(f"🚫 FRED API daily limit reached: {used}/{self.daily_limit}")
return None
# 3. FRED API 호출
params = {'series_id': series_id}
if start_date:
params['observation_start'] = start_date
if end_date:
params['observation_end'] = end_date
if limit:
params['limit'] = str(limit)
response_data = await self._make_fred_request('series/observations', params)
if not response_data or 'observations' not in response_data:
await self._log_api_usage(db, 'observations', series_id, params, False, 0)
return None
observations = response_data['observations']
# 4. 새 데이터만 추가 (기존 데이터는 유지 - 영구 저장)
existing_dates = set()
existing_query = await db.execute(
select(FredObservation.date).where(FredObservation.series_id == series_id)
)
existing_dates = {row[0] for row in existing_query.fetchall()}
# 5. 새로운 관측값만 저장 (중복 방지)
new_observations_count = 0
for obs_data in observations:
obs_date = obs_data['date']
if obs_date not in existing_dates:
new_obs = FredObservation(
series_id=series_id,
date=obs_date,
value=obs_data['value'],
realtime_start=obs_data.get('realtime_start'),
realtime_end=obs_data.get('realtime_end'),
cached_at=datetime.now()
)
db.add(new_obs)
new_observations_count += 1
else:
# 기존 데이터의 cached_at 업데이트 (최신성 표시)
update_query = await db.execute(
select(FredObservation).where(
and_(
FredObservation.series_id == series_id,
FredObservation.date == obs_date
)
)
)
existing_obs = update_query.scalar_one_or_none()
if existing_obs:
existing_obs.cached_at = datetime.now()
await db.commit()
await self._log_api_usage(db, 'observations', series_id, params, True, len(observations))
total_in_db = len(existing_dates) + new_observations_count
logger.info(f"✅ FRED observations processed: {series_id} ({new_observations_count} new, {total_in_db} total in DB)")
return {
'series_id': series_id,
'observations': observations,
'count': len(observations),
'cached': False,
'api_calls_remaining': remaining - 1
}
except Exception as e:
logger.error(f"❌ Error in get_series_observations: {e}")
return None
async def get_api_usage_stats(self, db: AsyncSession, days: int = 7) -> Dict:
"""API 사용량 통계 조회"""
try:
end_date = datetime.now()
start_date = end_date - timedelta(days=days)
# 기간별 사용량 조회 - 간단한 방식으로 변경
usage_query = await db.execute(
select(
FredApiUsage.date,
func.count(FredApiUsage.id).label('total_calls'),
func.count(FredApiUsage.id).filter(FredApiUsage.success == True).label('successful_calls'),
func.sum(FredApiUsage.response_size).label('total_records')
)
.where(FredApiUsage.date >= start_date.strftime('%Y-%m-%d'))
.group_by(FredApiUsage.date)
.order_by(desc(FredApiUsage.date))
)
daily_stats = []
for row in usage_query.fetchall():
daily_stats.append({
'date': row.date,
'total_calls': row.total_calls or 0,
'successful_calls': row.successful_calls or 0,
'total_records': row.total_records or 0,
'success_rate': (row.successful_calls / row.total_calls * 100) if row.total_calls > 0 else 0
})
# 오늘의 사용량
today = datetime.now().strftime('%Y-%m-%d')
can_call, used_today, remaining = await self._check_daily_limit(db)
# 캐시 통계
cache_stats_query = await db.execute(
select(
func.count(FredSeries.id).label('cached_series'),
func.count(FredObservation.id).label('cached_observations')
)
)
cache_row = cache_stats_query.fetchone()
return {
'success': True,
'data': {
'daily_limit': self.daily_limit,
'used_today': used_today,
'remaining_today': remaining,
'usage_percentage': (used_today / self.daily_limit * 100),
'can_make_requests': can_call,
'daily_stats': daily_stats,
'cache_stats': {
'cached_series': cache_row.cached_series or 0,
'cached_observations': cache_row.cached_observations or 0,
'cache_duration_hours': self.cache_duration_hours
}
}
}
except Exception as e:
logger.error(f"❌ Error getting API usage stats: {e}")
return {'success': False, 'error': str(e)}
# 싱글톤 인스턴스
fred_service = FredService()

@ -0,0 +1,542 @@
"""
News and Social Media Data Aggregation Service
Aggregates news and social media data from multiple sources for sentiment analysis:
- Yahoo Finance news (via yfinance_plus)
- NewsAPI
- Reddit API
"""
import asyncio
import aiohttp
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any, Union
from dataclasses import dataclass
import json
import re
import time
# Import yfinance_plus for news data
import sys
import os
sys.path.append(os.path.join(os.path.dirname(__file__), '../../yfinance_plus'))
from yfinance_plus import Ticker
logger = logging.getLogger(__name__)
@dataclass
class NewsArticle:
"""Standardized news article data structure"""
title: str
summary: Optional[str]
content: Optional[str]
url: str
source: str
published_at: datetime
author: Optional[str] = None
relevance_score: Optional[float] = None
image_url: Optional[str] = None
tags: List[str] = None
def __post_init__(self):
if self.tags is None:
self.tags = []
def to_dict(self) -> Dict:
"""Convert to dictionary for API response"""
return {
"title": self.title,
"summary": self.summary,
"content": self.content,
"url": self.url,
"source": self.source,
"published_at": self.published_at.isoformat() if self.published_at else None,
"author": self.author,
"relevance_score": self.relevance_score,
"image_url": self.image_url,
"tags": self.tags
}
@dataclass
class SocialPost:
"""Standardized social media post data structure"""
title: str
content: str
url: str
platform: str
author: str
published_at: datetime
score: Optional[int] = None
comments_count: Optional[int] = None
upvotes: Optional[int] = None
downvotes: Optional[int] = None
subreddit: Optional[str] = None
def to_dict(self) -> Dict:
"""Convert to dictionary for API response"""
return {
"title": self.title,
"content": self.content,
"url": self.url,
"platform": self.platform,
"author": self.author,
"published_at": self.published_at.isoformat() if self.published_at else None,
"score": self.score,
"comments_count": self.comments_count,
"upvotes": self.upvotes,
"downvotes": self.downvotes,
"subreddit": self.subreddit
}
class NewsAPIError(Exception):
"""News API related errors"""
pass
class RedditAPIError(Exception):
"""Reddit API related errors"""
pass
class NewsSocialService:
"""Service for aggregating news and social media data"""
def __init__(self):
# API credentials
self.newsapi_key = "04169755c1a34a4593316855c56adc3f"
self.reddit_client_id = "vVHvj_0Yj9wtmEjQoPYIIg"
self.reddit_client_secret = "jFRqcoryQFDIJbHeIIn93h18hUGFVg"
# Reddit access token (will be obtained dynamically)
self._reddit_token = None
self._reddit_token_expiry = None
# Rate limiting
self._last_newsapi_request = 0
self._last_reddit_request = 0
self._newsapi_rate_limit = 1.0 # 1 second between requests
self._reddit_rate_limit = 1.0 # 1 second between requests
async def get_ticker_news_and_social(
self,
ticker: str,
days_back: int = 7,
max_articles: int = 20,
max_social_posts: int = 15,
include_social: bool = True
) -> Dict[str, Any]:
"""
Get comprehensive news and social media data for a ticker
Args:
ticker: Stock ticker symbol (e.g., "AAPL", "TSLA")
days_back: Number of days to look back for articles
max_articles: Maximum number of news articles to return
max_social_posts: Maximum number of social media posts to return
include_social: Whether to include social media data
Returns:
Dictionary with news and social media data
"""
try:
# Run all data collection in parallel
tasks = []
# Yahoo Finance news
tasks.append(self._get_yahoo_news(ticker, max_articles // 3))
# NewsAPI
tasks.append(self._get_newsapi_articles(ticker, days_back, max_articles // 3))
# Reddit data (if enabled)
if include_social:
tasks.append(self._get_reddit_posts(ticker, days_back, max_social_posts))
else:
tasks.append(asyncio.create_task(self._empty_social_data()))
# Execute all tasks in parallel
yahoo_news, newsapi_articles, reddit_posts = await asyncio.gather(
*tasks, return_exceptions=True
)
# Handle exceptions
if isinstance(yahoo_news, Exception):
logger.error(f"Yahoo Finance news error: {yahoo_news}")
yahoo_news = []
if isinstance(newsapi_articles, Exception):
logger.error(f"NewsAPI error: {newsapi_articles}")
newsapi_articles = []
if isinstance(reddit_posts, Exception):
logger.error(f"Reddit API error: {reddit_posts}")
reddit_posts = []
# Combine and deduplicate articles
all_articles = []
all_articles.extend(yahoo_news)
all_articles.extend(newsapi_articles)
# Sort articles by published date (newest first)
all_articles.sort(key=lambda x: x.published_at or datetime.min, reverse=True)
# Limit total articles
if len(all_articles) > max_articles:
all_articles = all_articles[:max_articles]
# Sort social posts by score/engagement (if available)
if reddit_posts:
reddit_posts.sort(key=lambda x: x.score or 0, reverse=True)
if len(reddit_posts) > max_social_posts:
reddit_posts = reddit_posts[:max_social_posts]
# Compile response
response = {
"ticker": ticker.upper(),
"retrieved_at": datetime.now().isoformat(),
"news": {
"total_articles": len(all_articles),
"sources": {
"yahoo_finance": len([a for a in all_articles if a.source == "Yahoo Finance"]),
"newsapi": len([a for a in all_articles if a.source == "NewsAPI"]),
},
"articles": [article.to_dict() for article in all_articles]
},
"social_media": {
"total_posts": len(reddit_posts),
"platforms": {
"reddit": len(reddit_posts)
},
"posts": [post.to_dict() for post in reddit_posts] if include_social else []
},
"summary": {
"total_items": len(all_articles) + len(reddit_posts),
"time_range_days": days_back,
"oldest_item": min([a.published_at for a in all_articles + reddit_posts if a.published_at] or [datetime.now()]).isoformat() if all_articles + reddit_posts else datetime.now().isoformat(),
"newest_item": max([a.published_at for a in all_articles + reddit_posts if a.published_at] or [datetime.now()]).isoformat() if all_articles + reddit_posts else datetime.now().isoformat(),
}
}
return response
except Exception as e:
logger.error(f"Error getting news and social data for {ticker}: {e}")
raise
async def _empty_social_data(self) -> List[SocialPost]:
"""Return empty social data when social media is disabled"""
return []
async def _get_yahoo_news(self, ticker: str, max_articles: int) -> List[NewsArticle]:
"""Get news from Yahoo Finance via yfinance_plus"""
try:
logger.info(f"Fetching Yahoo Finance news for {ticker}")
# Use yfinance_plus to get news data
ticker_obj = Ticker(ticker.upper())
news_data = ticker_obj.news
articles = []
if news_data:
for item in news_data[:max_articles]:
try:
# Parse new Yahoo Finance news format from yfinance_plus
content = item.get('content', {})
# Extract publication date
published_at = None
if 'pubDate' in content:
# Parse ISO format: 2025-06-09T20:06:19Z
pub_date_str = content['pubDate'].replace('Z', '+00:00')
published_at = datetime.fromisoformat(pub_date_str).replace(tzinfo=None)
elif 'displayTime' in content:
# Parse ISO format: 2025-08-10T16:27:54Z
display_time_str = content['displayTime'].replace('Z', '+00:00')
published_at = datetime.fromisoformat(display_time_str).replace(tzinfo=None)
else:
published_at = datetime.now()
# Extract thumbnail URL
image_url = None
thumbnail = content.get('thumbnail', {})
if thumbnail and 'resolutions' in thumbnail:
resolutions = thumbnail['resolutions']
if resolutions and len(resolutions) > 0:
# Use the largest resolution (last one)
image_url = resolutions[-1].get('url')
# Extract URL
url = ''
if 'canonicalUrl' in content:
url = content['canonicalUrl'].get('url', '')
elif 'clickThroughUrl' in content:
url = content['clickThroughUrl'].get('url', '')
# Extract provider
provider = content.get('provider', {})
author = provider.get('displayName', 'Yahoo Finance')
article = NewsArticle(
title=content.get('title', ''),
summary=content.get('description', '') or content.get('summary', ''),
content=None, # Yahoo Finance doesn't provide full content
url=url,
source="Yahoo Finance",
published_at=published_at,
author=author,
image_url=image_url
)
articles.append(article)
except Exception as e:
logger.warning(f"Error parsing Yahoo Finance article: {e}")
continue
logger.info(f"Retrieved {len(articles)} articles from Yahoo Finance")
return articles
except Exception as e:
logger.error(f"Error fetching Yahoo Finance news for {ticker}: {e}")
return []
async def _get_newsapi_articles(self, ticker: str, days_back: int, max_articles: int) -> List[NewsArticle]:
"""Get news articles from NewsAPI"""
try:
# Rate limiting
await self._rate_limit_newsapi()
logger.info(f"Fetching NewsAPI articles for {ticker}")
# Calculate date range
from_date = (datetime.now() - timedelta(days=days_back)).strftime('%Y-%m-%d')
# NewsAPI endpoint
url = "https://newsapi.org/v2/everything"
params = {
"q": f'"{ticker}" OR "{ticker} stock" OR "{ticker} earnings"',
"from": from_date,
"sortBy": "publishedAt",
"pageSize": max_articles,
"apiKey": self.newsapi_key,
"language": "en"
}
async with aiohttp.ClientSession() as session:
async with session.get(url, params=params) as response:
if response.status == 200:
data = await response.json()
articles = []
for item in data.get('articles', []):
try:
# Parse NewsAPI format - make timezone naive for consistency
published_str = item['publishedAt'].replace('Z', '+00:00')
published_at = datetime.fromisoformat(published_str).replace(tzinfo=None)
article = NewsArticle(
title=item.get('title', ''),
summary=item.get('description', ''),
content=item.get('content', ''),
url=item.get('url', ''),
source="NewsAPI",
published_at=published_at,
author=item.get('author', ''),
image_url=item.get('urlToImage', '')
)
articles.append(article)
except Exception as e:
logger.warning(f"Error parsing NewsAPI article: {e}")
continue
logger.info(f"Retrieved {len(articles)} articles from NewsAPI")
return articles
else:
error_data = await response.json()
logger.error(f"NewsAPI error {response.status}: {error_data}")
raise NewsAPIError(f"NewsAPI returned {response.status}: {error_data}")
except Exception as e:
logger.error(f"Error fetching NewsAPI articles for {ticker}: {e}")
return []
async def _get_reddit_posts(self, ticker: str, days_back: int, max_posts: int) -> List[SocialPost]:
"""Get posts from Reddit related to the ticker"""
try:
# Get Reddit access token
await self._ensure_reddit_token()
# Rate limiting
await self._rate_limit_reddit()
logger.info(f"Fetching Reddit posts for {ticker}")
# Search multiple relevant subreddits
subreddits = [
"stocks", "investing", "SecurityAnalysis", "StockMarket",
"ValueInvesting", "financialindependence", "wallstreetbets"
]
all_posts = []
for subreddit in subreddits:
try:
await self._rate_limit_reddit()
# Search for ticker in subreddit
url = f"https://oauth.reddit.com/r/{subreddit}/search"
params = {
"q": f'"{ticker}" OR "${ticker}" OR "{ticker} stock"',
"restrict_sr": "true",
"sort": "hot",
"limit": max_posts // len(subreddits) + 1,
"t": "week" if days_back <= 7 else "month"
}
headers = {
"Authorization": f"Bearer {self._reddit_token}",
"User-Agent": "StockOracle/1.0.0"
}
async with aiohttp.ClientSession() as session:
async with session.get(url, params=params, headers=headers) as response:
if response.status == 200:
data = await response.json()
for item in data.get('data', {}).get('children', []):
try:
post_data = item.get('data', {})
# Filter out posts that are too old
created_utc = post_data.get('created_utc', 0)
post_date = datetime.fromtimestamp(created_utc)
if (datetime.now() - post_date).days > days_back:
continue
# Skip removed/deleted posts
if post_data.get('removed_by_category') or post_data.get('selftext') == '[removed]':
continue
post = SocialPost(
title=post_data.get('title', ''),
content=post_data.get('selftext', ''),
url=f"https://reddit.com{post_data.get('permalink', '')}",
platform="Reddit",
author=post_data.get('author', ''),
published_at=post_date,
score=post_data.get('score', 0),
comments_count=post_data.get('num_comments', 0),
upvotes=post_data.get('ups', 0),
downvotes=post_data.get('downs', 0),
subreddit=post_data.get('subreddit', '')
)
all_posts.append(post)
except Exception as e:
logger.warning(f"Error parsing Reddit post: {e}")
continue
elif response.status == 401:
logger.error("Reddit API authentication failed")
# Try to refresh token
self._reddit_token = None
await self._ensure_reddit_token()
else:
logger.warning(f"Reddit API error for r/{subreddit}: {response.status}")
except Exception as e:
logger.warning(f"Error fetching from r/{subreddit}: {e}")
continue
# Remove duplicates based on URL
seen_urls = set()
unique_posts = []
for post in all_posts:
if post.url not in seen_urls:
seen_urls.add(post.url)
unique_posts.append(post)
# Sort by score and limit
unique_posts.sort(key=lambda x: x.score or 0, reverse=True)
unique_posts = unique_posts[:max_posts]
logger.info(f"Retrieved {len(unique_posts)} posts from Reddit")
return unique_posts
except Exception as e:
logger.error(f"Error fetching Reddit posts for {ticker}: {e}")
return []
async def _ensure_reddit_token(self):
"""Ensure we have a valid Reddit access token"""
if self._reddit_token and self._reddit_token_expiry and datetime.now() < self._reddit_token_expiry:
return
logger.info("Obtaining Reddit access token")
try:
# Reddit OAuth2 client credentials flow
auth_url = "https://www.reddit.com/api/v1/access_token"
auth_data = {
"grant_type": "client_credentials"
}
headers = {
"User-Agent": "StockOracle/1.0.0"
}
auth = aiohttp.BasicAuth(self.reddit_client_id, self.reddit_client_secret)
async with aiohttp.ClientSession() as session:
async with session.post(auth_url, data=auth_data, auth=auth, headers=headers) as response:
if response.status == 200:
token_data = await response.json()
self._reddit_token = token_data.get('access_token')
expires_in = token_data.get('expires_in', 3600)
self._reddit_token_expiry = datetime.now() + timedelta(seconds=expires_in - 60)
logger.info("Successfully obtained Reddit access token")
else:
error_data = await response.text()
logger.error(f"Reddit auth error {response.status}: {error_data}")
raise RedditAPIError(f"Failed to authenticate with Reddit: {response.status}")
except Exception as e:
logger.error(f"Error obtaining Reddit token: {e}")
raise
async def _rate_limit_newsapi(self):
"""Apply rate limiting for NewsAPI"""
now = time.time()
time_since_last = now - self._last_newsapi_request
if time_since_last < self._newsapi_rate_limit:
await asyncio.sleep(self._newsapi_rate_limit - time_since_last)
self._last_newsapi_request = time.time()
async def _rate_limit_reddit(self):
"""Apply rate limiting for Reddit API"""
now = time.time()
time_since_last = now - self._last_reddit_request
if time_since_last < self._reddit_rate_limit:
await asyncio.sleep(self._reddit_rate_limit - time_since_last)
self._last_reddit_request = time.time()
# Global service instance
news_social_service = NewsSocialService()
# Export for use in other modules
__all__ = ["news_social_service", "NewsArticle", "SocialPost", "NewsSocialService"]

@ -0,0 +1,942 @@
"""
Service for fetching and processing price data from Yahoo Finance using yfinance-plus
"""
from datetime import datetime, timezone, timedelta, date
from typing import Dict, List, Optional, Tuple, Union
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, desc
import asyncio
import sys
import os
# Add parent directory to path for imports
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from app.models.financial import PriceData
from app.schemas.financial import DataSource, ErrorType
from app.utils.date_utils import parse_period, quarters_to_date_range, resolve_time_parameters
from app.core.config import settings
logger = logging.getLogger(__name__)
# Import yfinance-plus for price data only
try:
import yfinance_plus as yf
YFINANCE_AVAILABLE = True
logger.info("yfinance-plus imported successfully for price data")
except ImportError:
logger.error("yfinance-plus not available for price data")
YFINANCE_AVAILABLE = False
class PriceDataService:
def __init__(self):
self.yf_available = YFINANCE_AVAILABLE
if not self.yf_available:
logger.warning("Yahoo Finance (yfinance-plus) data will not be available")
async def get_or_update_price_data(
self,
db: AsyncSession,
ticker: str,
start_date: datetime,
end_date: datetime,
interval: str = "1d",
force_refresh: bool = False
) -> List[PriceData]:
"""
Get price data from database or fetch from Yahoo Finance if needed
Args:
db: Database session
ticker: Stock ticker symbol
start_date: Start date for data retrieval
end_date: End date for data retrieval
interval: Data interval (1d, 1w, 1m, 1h, etc.)
force_refresh: Force refresh data from Yahoo Finance
Returns:
List of PriceData objects
"""
ticker = ticker.upper()
# Check if we need to fetch new data
missing_periods = await self._check_missing_periods(
db, ticker, start_date, end_date, interval
)
if missing_periods or force_refresh:
if not self.yf_available:
raise ValueError("Yahoo Finance (yfinance-plus) data source not available")
# Fetch data from Yahoo Finance using yfinance-plus
await self._fetch_and_store_price_data(
db, ticker, start_date, end_date, interval
)
# Retrieve data from database
price_data = await self._get_price_data_from_db(
db, ticker, start_date, end_date, interval
)
return price_data
async def _check_missing_periods(
self,
db: AsyncSession,
ticker: str,
start_date: datetime,
end_date: datetime,
interval: str
) -> List[datetime]:
"""Check which periods are missing in the database"""
# If no date range provided, assume we need to fetch data
if start_date is None or end_date is None:
return [datetime.now()] # Return a dummy date to trigger fetch
# Check if we have any data for this ticker and interval
result = await db.execute(
select(PriceData.date)
.where(
and_(
PriceData.ticker == ticker,
PriceData.date >= start_date,
PriceData.date <= end_date
)
)
.order_by(PriceData.date)
)
existing_dates = {row[0].date() for row in result.fetchall()}
# Generate expected dates based on interval
expected_dates = self._generate_expected_dates(start_date, end_date, interval)
# Find missing dates
missing_dates = [date for date in expected_dates if date not in existing_dates]
# If more than 10% of dates are missing, consider it as needing refresh
if len(missing_dates) > len(expected_dates) * 0.1:
return missing_dates
return []
def _generate_expected_dates(
self,
start_date: datetime,
end_date: datetime,
interval: str
) -> List[datetime]:
"""Generate expected trading dates based on interval"""
expected_dates = []
current_date = start_date
# Simple date generation (doesn't account for market holidays)
if interval == "1d":
while current_date <= end_date:
# Skip weekends for daily data
if current_date.weekday() < 5: # Monday = 0, Friday = 4
expected_dates.append(current_date)
current_date += timedelta(days=1)
elif interval == "1w":
while current_date <= end_date:
expected_dates.append(current_date)
current_date += timedelta(weeks=1)
elif interval == "1m":
# Monthly data - first day of each month
while current_date <= end_date:
expected_dates.append(current_date)
# Move to next month
if current_date.month == 12:
current_date = current_date.replace(year=current_date.year + 1, month=1)
else:
current_date = current_date.replace(month=current_date.month + 1)
else:
# For other intervals, just return the date range
expected_dates = [start_date, end_date]
return expected_dates
async def _fetch_and_store_price_data(
self,
db: AsyncSession,
ticker: str,
start_date: datetime,
end_date: datetime,
interval: str
):
"""Fetch price data from Yahoo Finance using yfinance-plus and store in database"""
try:
logger.info(f"Fetching price data for {ticker} from {start_date} to {end_date}")
# Create yfinance-plus ticker object
yf_ticker = yf.Ticker(ticker)
# Fetch historical data
# Convert dates to strings in YYYY-MM-DD format
start_str = start_date.strftime('%Y-%m-%d')
# yfinance's `end` parameter is exclusive for daily data when using date strings.
# Add +1 day to include the intended end_date day in the results.
from datetime import timedelta
end_inclusive = end_date + timedelta(days=1)
end_str = end_inclusive.strftime('%Y-%m-%d')
# Run yfinance-plus in executor to avoid blocking
loop = asyncio.get_event_loop()
hist_data = await loop.run_in_executor(
None,
lambda: yf_ticker.history(
start=start_str,
end=end_str,
interval=interval,
auto_adjust=True,
prepost=False,
period=None # Explicitly set period to None when using start/end dates
)
)
if hist_data.empty:
logger.warning(f"No price data returned for {ticker}")
return
# Store data in database
await self._store_price_data(db, ticker, hist_data, interval)
await db.commit()
logger.info(f"Successfully stored {len(hist_data)} price records for {ticker}")
except Exception as e:
logger.error(f"Error fetching price data for {ticker}: {str(e)}")
await db.rollback()
raise
async def get_quote(self, ticker: str, use_prepost: bool = True) -> Dict:
"""Get latest quote using yfinance-plus .info fields with fallback to fast history last row."""
if not self.yf_available:
raise ValueError("Yahoo Finance (yfinance-plus) data source not available")
try:
yf_ticker = yf.Ticker(ticker)
loop = asyncio.get_event_loop()
info = await loop.run_in_executor(None, lambda: yf_ticker.info)
# Prefer regular/post/pre values
regular = info.get("regularMarketPrice")
post = info.get("postMarketPrice") if use_prepost else None
pre = info.get("preMarketPrice") if use_prepost else None
price = post or pre or regular
currency = info.get("currency")
exchange = info.get("exchange") or info.get("fullExchangeName")
market_state = info.get("marketState")
ts = info.get("regularMarketTime") or info.get("postMarketTime") or info.get("preMarketTime")
if isinstance(ts, (int, float)):
ts = datetime.fromtimestamp(ts, tz=timezone.utc)
elif isinstance(ts, datetime):
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
else:
ts = datetime.now(timezone.utc)
return {
"ticker": ticker.upper(),
"price": float(price) if price is not None else None,
"regular_price": float(regular) if regular is not None else None,
"pre_market_price": float(pre) if pre is not None else None,
"post_market_price": float(post) if post is not None else None,
"currency": currency,
"exchange": exchange,
"market_state": market_state,
"timestamp": ts,
"source": DataSource.YAHOO_FINANCE,
"delayed": True,
}
except Exception as e:
logger.error(f"Error fetching quote for {ticker}: {str(e)}")
raise
async def get_intraday(self, ticker: str, interval: str = "1m", period: str = "1d") -> List[Dict]:
"""Get intraday candles using yfinance-plus history with period/interval."""
if not self.yf_available:
raise ValueError("Yahoo Finance (yfinance-plus) data source not available")
try:
yf_ticker = yf.Ticker(ticker)
loop = asyncio.get_event_loop()
df = await loop.run_in_executor(
None,
lambda: yf_ticker.history(period=period, interval=interval, auto_adjust=True, prepost=True)
)
candles = []
if not df.empty:
for ts, row in df.iterrows():
dt = ts.to_pydatetime()
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
candles.append({
"timestamp": dt,
"open": float(row.get("Open", 0)) if not pd.isna(row.get("Open")) else None,
"high": float(row.get("High", 0)) if not pd.isna(row.get("High")) else None,
"low": float(row.get("Low", 0)) if not pd.isna(row.get("Low")) else None,
"close": float(row.get("Close", 0)) if not pd.isna(row.get("Close")) else 0.0,
"volume": float(row.get("Volume", 0)) if not pd.isna(row.get("Volume")) else None,
})
return candles
except Exception as e:
logger.error(f"Error fetching intraday for {ticker}: {str(e)}")
raise
async def get_today_ohlc(self, ticker: str) -> Dict:
"""Get today's OHLC. If daily not yet finalized, aggregate from intraday 1m."""
if not self.yf_available:
raise ValueError("Yahoo Finance (yfinance-plus) data source not available")
try:
# First try daily with period=1d
yf_ticker = yf.Ticker(ticker)
loop = asyncio.get_event_loop()
daily = await loop.run_in_executor(
None, lambda: yf_ticker.history(period="1d", interval="1d", auto_adjust=True, prepost=False)
)
if daily is not None and not daily.empty:
ts, row = list(daily.iterrows())[-1]
d = ts.to_pydatetime().date()
return {
"ticker": ticker.upper(),
"date": d,
"open": float(row.get("Open", 0)) if not pd.isna(row.get("Open")) else None,
"high": float(row.get("High", 0)) if not pd.isna(row.get("High")) else None,
"low": float(row.get("Low", 0)) if not pd.isna(row.get("Low")) else None,
"close": float(row.get("Close", 0)) if not pd.isna(row.get("Close")) else 0.0,
"volume": float(row.get("Volume", 0)) if not pd.isna(row.get("Volume")) else None,
"source": DataSource.YAHOO_FINANCE,
"method": "daily",
}
# Fallback to intraday aggregation
intraday = await self.get_intraday(ticker, interval="1m", period="1d")
if not intraday:
raise ValueError("No intraday data available for today")
o = next((c["open"] for c in intraday if c.get("open") is not None), None)
h = max((c.get("high") or c.get("close") or 0.0) for c in intraday)
l = min((c.get("low") or c.get("close") or float("inf")) for c in intraday)
c = next((candle.get("close") for candle in reversed(intraday) if candle.get("close") is not None), 0.0)
v = sum((c.get("volume") or 0.0) for c in intraday)
today_date = intraday[0]["timestamp"].date()
return {
"ticker": ticker.upper(),
"date": today_date,
"open": o,
"high": h if h != 0.0 else None,
"low": l if l != float("inf") else None,
"close": c,
"volume": v or None,
"source": DataSource.YAHOO_FINANCE,
"method": "intraday_aggregate",
}
except Exception as e:
logger.error(f"Error fetching today OHLC for {ticker}: {str(e)}")
raise
async def _store_price_data(
self,
db: AsyncSession,
ticker: str,
hist_data,
interval: str
):
"""Store price data in database"""
for date, row in hist_data.iterrows():
# Convert pandas timestamp to datetime
price_date = date.to_pydatetime()
if price_date.tzinfo is None:
price_date = price_date.replace(tzinfo=timezone.utc)
# Check if record already exists
existing = await db.execute(
select(PriceData).where(
and_(
PriceData.ticker == ticker,
PriceData.date == price_date
)
)
)
if existing.first():
continue # Skip if already exists
# Create new price data record
price_record = PriceData(
ticker=ticker,
date=price_date,
open=float(row.get('Open', 0)) if not pd.isna(row.get('Open')) else None,
high=float(row.get('High', 0)) if not pd.isna(row.get('High')) else None,
low=float(row.get('Low', 0)) if not pd.isna(row.get('Low')) else None,
close=float(row.get('Close', 0)) if not pd.isna(row.get('Close')) else 0,
volume=float(row.get('Volume', 0)) if not pd.isna(row.get('Volume')) else None,
adjusted_close=float(row.get('Close', 0)) if not pd.isna(row.get('Close')) else None, # Auto-adjusted
data_source=DataSource.YAHOO_FINANCE
)
db.add(price_record)
async def _get_price_data_from_db(
self,
db: AsyncSession,
ticker: str,
start_date: datetime,
end_date: datetime,
interval: str
) -> List[PriceData]:
"""Get price data from database"""
# Build query conditions
conditions = [PriceData.ticker == ticker]
if start_date is not None:
conditions.append(PriceData.date >= start_date)
if end_date is not None:
conditions.append(PriceData.date <= end_date)
result = await db.execute(
select(PriceData)
.where(and_(*conditions))
.order_by(PriceData.date)
)
return result.scalars().all()
async def get_latest_price(
self,
db: AsyncSession,
ticker: str
) -> Optional[PriceData]:
"""Get the latest price for a ticker"""
result = await db.execute(
select(PriceData)
.where(PriceData.ticker == ticker.upper())
.order_by(desc(PriceData.date))
.limit(1)
)
return result.scalar_one_or_none()
async def get_ticker_info(self, ticker: str) -> Dict:
"""Get ticker information from Yahoo Finance using yfinance-plus"""
if not self.yf_available:
raise ValueError("Yahoo Finance (yfinance-plus) data source not available")
try:
yf_ticker = yf.Ticker(ticker)
# Run in executor to avoid blocking
loop = asyncio.get_event_loop()
info = await loop.run_in_executor(None, lambda: yf_ticker.info)
return info
except Exception as e:
logger.error(f"Error fetching ticker info for {ticker}: {str(e)}")
raise
async def get_multiple_tickers_data(
self,
db: AsyncSession,
tickers: List[str],
start_date: datetime,
end_date: datetime,
interval: str = "1d",
force_refresh: bool = False
) -> Dict[str, List[PriceData]]:
"""Get price data for multiple tickers (legacy method)"""
results = {}
for ticker in tickers:
try:
data = await self.get_or_update_price_data(
db, ticker, start_date, end_date, interval, force_refresh
)
results[ticker] = data
except Exception as e:
logger.error(f"Error fetching data for {ticker}: {str(e)}")
results[ticker] = []
return results
async def get_multiple_tickers_data_optimized(
self,
db: AsyncSession,
tickers: List[str],
start_date: datetime,
end_date: datetime,
interval: str = "1d",
force_refresh: bool = False
) -> Tuple[List, int, int]:
"""
Optimized bulk processing for multiple tickers with chunking for 100+ tickers:
1. Smart chunking to handle 100+ tickers efficiently
2. Parallel processing using asyncio with concurrency limits
3. Bulk yfinance queries using yfinance-plus bulk features
4. Optimized database operations with batch processing
5. Progress tracking for large requests
Supports unlimited ticker count with intelligent chunking:
- Small batches (≤50): Process in single chunk
- Medium batches (51-200): Process in 2-4 chunks
- Large batches (200+): Process in optimal chunks with progress tracking
Returns:
Tuple of (results, successful_count, failed_count) for API response
"""
from app.schemas.financial import BulkPriceDataItem, PriceDataResponse, PriceDataPoint
results = []
successful_count = 0
failed_count = 0
# Normalize tickers and validate
tickers = [t.upper().strip() for t in tickers if t.strip()]
total_tickers = len(tickers)
logger.info(f"Starting bulk processing for {total_tickers} tickers")
# Determine optimal chunking strategy based on ticker count
if total_tickers <= 50:
chunk_size = total_tickers # Single chunk for small requests
max_concurrent = 1
elif total_tickers <= 200:
chunk_size = 50 # Moderate chunks for medium requests
max_concurrent = 4
else:
chunk_size = 75 # Larger chunks for big requests
max_concurrent = 6
try:
# Process tickers in chunks to avoid overwhelming APIs and memory
all_results = []
all_successful = 0
all_failed = 0
for chunk_start in range(0, total_tickers, chunk_size):
chunk_end = min(chunk_start + chunk_size, total_tickers)
chunk_tickers = tickers[chunk_start:chunk_end]
chunk_num = (chunk_start // chunk_size) + 1
total_chunks = (total_tickers + chunk_size - 1) // chunk_size
logger.info(f"Processing chunk {chunk_num}/{total_chunks}: {len(chunk_tickers)} tickers")
# Step 1: Batch check missing periods for chunk
missing_tickers = []
if force_refresh:
missing_tickers = chunk_tickers.copy()
else:
missing_tickers = await self._batch_check_missing_periods(
db, chunk_tickers, start_date, end_date, interval
)
# Step 2: If we have missing data, use bulk yfinance fetch
if missing_tickers and self.yf_available:
logger.info(f"Bulk fetching price data for {len(missing_tickers)} tickers in chunk {chunk_num}")
await self._bulk_fetch_and_store_price_data(
db, missing_tickers, start_date, end_date, interval
)
# Step 3: Batch retrieve all data from database for this chunk
ticker_data_map = await self._batch_get_price_data_from_db(
db, chunk_tickers, start_date, end_date, interval
)
# Step 4: Process results for this chunk
chunk_results = []
chunk_successful = 0
chunk_failed = 0
for ticker in chunk_tickers:
try:
price_data = ticker_data_map.get(ticker, [])
# Convert to response models
price_points = [
PriceDataPoint.model_validate(pd) for pd in price_data
]
# Calculate actual date range from returned data
actual_start_date = start_date
actual_end_date = end_date
if price_points:
# Get actual start and end dates from the data
actual_start_date = min(point.date for point in price_points)
actual_end_date = max(point.date for point in price_points)
response = PriceDataResponse(
ticker=ticker,
interval=interval,
data=price_points,
metadata={
"request_id": str(ticker),
"data_points": len(price_points),
"interval": interval,
"date_range": {
"start": actual_start_date.isoformat(),
"end": actual_end_date.isoformat()
},
"last_updated": datetime.now(timezone.utc).isoformat()
}
)
chunk_results.append(BulkPriceDataItem(
ticker=ticker,
success=True,
data=response,
error=None
))
chunk_successful += 1
except Exception as e:
# Handle individual ticker failure
error_message = str(e)
if "No price data found" in error_message or "No data returned" in error_message:
error_message = f"No price data found for ticker {ticker}"
elif "Invalid ticker" in error_message:
error_message = f"Invalid or unknown ticker: {ticker}"
elif "Yahoo Finance data source not available" in error_message:
error_message = "Yahoo Finance data source not available"
chunk_results.append(BulkPriceDataItem(
ticker=ticker,
success=False,
data=None,
error=error_message
))
chunk_failed += 1
# Aggregate chunk results
all_results.extend(chunk_results)
all_successful += chunk_successful
all_failed += chunk_failed
logger.info(f"Chunk {chunk_num} completed: {chunk_successful} successful, {chunk_failed} failed")
# Small delay between chunks to avoid overwhelming APIs
if chunk_num < total_chunks:
await asyncio.sleep(0.2)
logger.info(f"Bulk processing completed: {all_successful} successful, {all_failed} failed out of {total_tickers} total")
return all_results, all_successful, all_failed
except Exception as e:
logger.error(f"Error in optimized bulk processing: {str(e)}")
# Fallback to individual processing
return await self._fallback_individual_processing(
db, tickers, start_date, end_date, interval, force_refresh
)
async def _batch_check_missing_periods(
self,
db: AsyncSession,
tickers: List[str],
start_date: datetime,
end_date: datetime,
interval: str
) -> List[str]:
"""Batch check which tickers have missing periods"""
# Single query to check all tickers at once
from sqlalchemy import func, case
result = await db.execute(
select(
PriceData.ticker,
func.count(PriceData.date).label('count'),
func.min(PriceData.date).label('min_date'),
func.max(PriceData.date).label('max_date')
)
.where(
and_(
PriceData.ticker.in_(tickers),
PriceData.date >= start_date,
PriceData.date <= end_date
)
)
.group_by(PriceData.ticker)
)
existing_tickers = {}
for row in result.fetchall():
ticker, count, min_date, max_date = row
existing_tickers[ticker] = {
'count': count,
'min_date': min_date,
'max_date': max_date
}
# Determine expected count based on interval
expected_days = (end_date - start_date).days
if interval == "1d":
expected_count = expected_days * 0.7 # Rough estimate for trading days
elif interval == "1w":
expected_count = expected_days / 7
else:
expected_count = 1
missing_tickers = []
for ticker in tickers:
ticker_data = existing_tickers.get(ticker)
if not ticker_data or ticker_data['count'] < expected_count * 0.8:
missing_tickers.append(ticker)
logger.info(f"Found {len(missing_tickers)} tickers needing data refresh out of {len(tickers)}")
return missing_tickers
async def _bulk_fetch_and_store_price_data(
self,
db: AsyncSession,
tickers: List[str],
start_date: datetime,
end_date: datetime,
interval: str
):
"""Optimized bulk fetch using yfinance-plus bulk features"""
try:
logger.info(f"Starting bulk fetch for {len(tickers)} tickers")
# Convert dates to strings
start_str = start_date.strftime('%Y-%m-%d')
end_str = end_date.strftime('%Y-%m-%d')
# Use yfinance-plus bulk download feature
loop = asyncio.get_event_loop()
# Use adaptive chunk size for yfinance API calls based on ticker count
# Smaller chunks for yfinance API calls to avoid overwhelming the service
total_tickers = len(tickers)
if total_tickers <= 10:
chunk_size = total_tickers # Single chunk for very small batches
elif total_tickers <= 50:
chunk_size = 15 # Small chunks for moderate batches
else:
chunk_size = 20 # Standard chunks for large batches
for i in range(0, len(tickers), chunk_size):
chunk_tickers = tickers[i:i + chunk_size]
logger.info(f"Processing chunk {i//chunk_size + 1}: {len(chunk_tickers)} tickers")
# Use yfinance-plus bulk download
bulk_data = await loop.run_in_executor(
None,
lambda: yf.download(
tickers=' '.join(chunk_tickers),
start=start_str,
end=end_str,
interval=interval,
auto_adjust=True,
prepost=False,
group_by='ticker',
threads=True # Enable multi-threading
)
)
# Process and store data for each ticker in the chunk
await self._process_bulk_data(db, chunk_tickers, bulk_data, interval)
# Small delay to be nice to the API
await asyncio.sleep(0.1)
await db.commit()
logger.info(f"Successfully completed bulk fetch for {len(tickers)} tickers")
except Exception as e:
logger.error(f"Error in bulk fetch: {str(e)}")
await db.rollback()
raise
async def _process_bulk_data(
self,
db: AsyncSession,
tickers: List[str],
bulk_data,
interval: str
):
"""Process bulk data returned from yfinance and store in database"""
if bulk_data.empty:
logger.warning("No bulk data returned from yfinance")
return
# Handle different data structures from yfinance bulk download
if len(tickers) == 1:
# Single ticker - data is a simple DataFrame
await self._store_ticker_data(db, tickers[0], bulk_data, interval)
else:
# Multiple tickers - data is grouped by ticker
for ticker in tickers:
try:
if ticker in bulk_data.columns.get_level_values(0):
ticker_data = bulk_data[ticker]
if not ticker_data.empty:
await self._store_ticker_data(db, ticker, ticker_data, interval)
except Exception as e:
logger.error(f"Error processing data for {ticker}: {str(e)}")
continue
async def _store_ticker_data(
self,
db: AsyncSession,
ticker: str,
ticker_data,
interval: str
):
"""Store individual ticker data with optimized batch operations"""
# Batch check existing dates to avoid individual DB queries
existing_dates = await self._get_existing_dates_for_ticker(db, ticker)
new_records = []
for date, row in ticker_data.iterrows():
# Convert pandas timestamp to datetime
price_date = date.to_pydatetime()
if price_date.tzinfo is None:
price_date = price_date.replace(tzinfo=timezone.utc)
# Skip if already exists
if price_date.date() in existing_dates:
continue
# Prepare new record
price_record = PriceData(
ticker=ticker,
date=price_date,
open=float(row.get('Open', 0)) if not pd.isna(row.get('Open')) else None,
high=float(row.get('High', 0)) if not pd.isna(row.get('High')) else None,
low=float(row.get('Low', 0)) if not pd.isna(row.get('Low')) else None,
close=float(row.get('Close', 0)) if not pd.isna(row.get('Close')) else 0,
volume=float(row.get('Volume', 0)) if not pd.isna(row.get('Volume')) else None,
adjusted_close=float(row.get('Close', 0)) if not pd.isna(row.get('Close')) else None,
data_source=DataSource.YAHOO_FINANCE
)
new_records.append(price_record)
# Batch insert new records
if new_records:
db.add_all(new_records)
logger.info(f"Added {len(new_records)} new price records for {ticker}")
async def _get_existing_dates_for_ticker(
self,
db: AsyncSession,
ticker: str
) -> set:
"""Get existing dates for a ticker to avoid duplicates"""
result = await db.execute(
select(PriceData.date)
.where(PriceData.ticker == ticker)
)
return {row[0].date() for row in result.fetchall()}
async def _batch_get_price_data_from_db(
self,
db: AsyncSession,
tickers: List[str],
start_date: datetime,
end_date: datetime,
interval: str
) -> Dict[str, List[PriceData]]:
"""Batch retrieve price data for multiple tickers"""
# Single query to get data for all tickers
result = await db.execute(
select(PriceData)
.where(
and_(
PriceData.ticker.in_(tickers),
PriceData.date >= start_date,
PriceData.date <= end_date
)
)
.order_by(PriceData.ticker, PriceData.date)
)
# Group results by ticker
ticker_data_map = {}
for ticker in tickers:
ticker_data_map[ticker] = []
for record in result.scalars().all():
if record.ticker in ticker_data_map:
ticker_data_map[record.ticker].append(record)
return ticker_data_map
async def _fallback_individual_processing(
self,
db: AsyncSession,
tickers: List[str],
start_date: datetime,
end_date: datetime,
interval: str,
force_refresh: bool
) -> Tuple[List, int, int]:
"""Fallback to individual processing if bulk processing fails"""
from app.schemas.financial import BulkPriceDataItem, PriceDataResponse, PriceDataPoint
logger.warning("Falling back to individual ticker processing")
results = []
successful_count = 0
failed_count = 0
for ticker in tickers:
try:
# Get price data
price_data = await self.get_or_update_price_data(
db, ticker, start_date, end_date, interval, force_refresh
)
# Convert to response models
price_points = [
PriceDataPoint.model_validate(pd) for pd in price_data
]
# Calculate actual date range from returned data
actual_start_date = start_date
actual_end_date = end_date
if price_points:
actual_start_date = min(point.date for point in price_points)
actual_end_date = max(point.date for point in price_points)
response = PriceDataResponse(
ticker=ticker,
interval=interval,
data=price_points,
metadata={
"request_id": str(ticker),
"data_points": len(price_points),
"interval": interval,
"date_range": {
"start": actual_start_date.isoformat(),
"end": actual_end_date.isoformat()
},
"last_updated": datetime.now(timezone.utc).isoformat()
}
)
results.append(BulkPriceDataItem(
ticker=ticker,
success=True,
data=response,
error=None
))
successful_count += 1
except Exception as e:
error_message = str(e)
results.append(BulkPriceDataItem(
ticker=ticker,
success=False,
data=None,
error=error_message
))
failed_count += 1
return results, successful_count, failed_count
# Import pandas for data processing
try:
import pandas as pd
except ImportError:
logger.error("pandas not available - price data service will not work")
pd = None

@ -0,0 +1,339 @@
"""
Real SEC Financial Data Service
Uses direct SEC EDGAR API to fetch actual SEC filing data and yfinance-plus for price data only
"""
from datetime import datetime, timezone, timedelta
from typing import Dict, List, Optional, Tuple
import logging
import numpy as np
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_, desc
from app.models.financial import Company, FinancialData, CalculatedMetrics, PriceData
from app.schemas.financial import DataSource
from app.services.price_data_service import PriceDataService
from app.services.sec_edgar_service import SECEdgarService
from app.core.config import settings
# Import yfinance-plus only for price data
try:
import yfinance_plus as yf
YFINANCE_AVAILABLE = True
logger = logging.getLogger(__name__)
logger.info("yfinance-plus imported for price data only")
except ImportError:
logger = logging.getLogger(__name__)
logger.error("yfinance-plus not available for price data")
YFINANCE_AVAILABLE = False
logger = logging.getLogger(__name__)
class RealSECFinancialService:
"""Real financial service that uses actual SEC EDGAR API"""
def __init__(self):
self.price_service = PriceDataService()
self.sec_service = SECEdgarService()
logger.info("SEC EDGAR service initialized")
async def get_or_create_company_data(
self,
db: AsyncSession,
ticker: str,
start_date: datetime,
end_date: datetime,
force_refresh: bool = False
) -> Dict:
"""
Get company data with real SEC financial data
"""
ticker = ticker.upper()
# Get or create company
company = await self._get_or_create_company(db, ticker)
# Get real financial data from SEC EDGAR API
financial_data = await self.sec_service.get_financial_data(
db, ticker, start_date, end_date, force_refresh
)
# Get price data for calculations
price_data = await self._get_price_data_for_period(
db, ticker, start_date, end_date
)
# Calculate metrics using real data
calculated_metrics = await self._calculate_real_metrics(
db, ticker, financial_data, price_data, force_refresh
)
return {
"company": company,
"financial_data": financial_data,
"calculated_metrics": calculated_metrics
}
async def _get_or_create_company(self, db: AsyncSession, ticker: str) -> Company:
"""Get or create company record using real SEC data"""
result = await db.execute(
select(Company).where(Company.ticker == ticker)
)
company = result.scalar_one_or_none()
if not company:
# Get real company info from SEC
company_info = await self._fetch_company_info_from_sec(ticker)
company = Company(
ticker=ticker,
name=company_info["name"],
cik=company_info["cik"],
sector=company_info["sector"],
industry=company_info["industry"],
business_description=company_info["business_description"],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.add(company)
await db.commit()
await db.refresh(company)
return company
async def _fetch_company_info_from_sec(self, ticker: str) -> Dict:
"""Fetch real company information from SEC EDGAR API"""
try:
return await self.sec_service.get_company_info(ticker)
except Exception as e:
logger.error(f"Error fetching SEC company info for {ticker}: {e}")
return self._get_fallback_company_info(ticker)
def _get_fallback_company_info(self, ticker: str) -> Dict:
"""Fallback company info if SEC is not available"""
company_defaults = {
'AAPL': {
'name': 'Apple Inc.',
'cik': '0000320193',
'sector': 'Technology',
'industry': 'Consumer Electronics',
'business_description': 'Technology company designing and manufacturing consumer electronics'
},
'MSFT': {
'name': 'Microsoft Corporation',
'cik': '0000789019',
'sector': 'Technology',
'industry': 'Software—Infrastructure',
'business_description': 'Software and cloud services company'
},
'TSLA': {
'name': 'Tesla Inc.',
'cik': '0001318605',
'sector': 'Consumer Cyclical',
'industry': 'Auto Manufacturers',
'business_description': 'Electric vehicle and clean energy company'
},
'NVDA': {
'name': 'NVIDIA Corporation',
'cik': '0001045810',
'sector': 'Technology',
'industry': 'Semiconductors',
'business_description': 'Semiconductor company specializing in graphics processing units'
}
}
return company_defaults.get(ticker, {
'name': f'{ticker} Corporation',
'cik': f'000{hash(ticker) % 1000000:06d}',
'sector': 'Technology',
'industry': 'Software',
'business_description': f'{ticker} technology company'
})
# SEC financial data fetching is now handled by SECEdgarService
# Financial data fetching is now handled by SECEdgarService only
# yfinance is only used for price data via PriceDataService
# All financial data processing is now handled by SECEdgarService
async def _get_price_data_for_period(
self,
db: AsyncSession,
ticker: str,
start_date: datetime,
end_date: datetime
) -> List[PriceData]:
"""Get price data for the specified period"""
return await self.price_service.get_or_update_price_data(
db, ticker, start_date, end_date, "1d", force_refresh=False
)
async def _calculate_real_metrics(
self,
db: AsyncSession,
ticker: str,
financial_data: List[FinancialData],
price_data: List[PriceData],
force_refresh: bool = False
) -> List[CalculatedMetrics]:
"""Calculate metrics using real financial and price data"""
calculated_metrics = []
for financial_record in financial_data:
period_date = financial_record.period_date
# Check if metrics already exist
existing_metrics = None
if not force_refresh:
existing = await db.execute(
select(CalculatedMetrics).where(
and_(
CalculatedMetrics.ticker == ticker,
CalculatedMetrics.period_date == period_date
)
).limit(1)
)
existing_metrics = existing.scalar_one_or_none()
if existing_metrics:
calculated_metrics.append(existing_metrics)
continue
# Find price data close to the period date
price_at_period = self._find_price_near_date(price_data, period_date)
if not price_at_period:
logger.warning(f"No price data found for {ticker} near {period_date}")
continue
# Calculate valuation metrics using real price and real financial data
market_cap = None
if price_at_period.close and financial_record.shares_outstanding:
market_cap = price_at_period.close * financial_record.shares_outstanding
pe_ratio = None
if financial_record.eps and financial_record.eps > 0 and price_at_period.close:
pe_ratio = price_at_period.close / financial_record.eps
pb_ratio = None
if (financial_record.total_equity and financial_record.shares_outstanding and
financial_record.shares_outstanding > 0 and price_at_period.close):
book_value_per_share = financial_record.total_equity / financial_record.shares_outstanding
if book_value_per_share > 0:
pb_ratio = price_at_period.close / book_value_per_share
ps_ratio = None
if (financial_record.revenue and financial_record.shares_outstanding and
financial_record.shares_outstanding > 0 and price_at_period.close):
revenue_per_share = financial_record.revenue / financial_record.shares_outstanding
if revenue_per_share > 0:
ps_ratio = price_at_period.close / revenue_per_share
# Calculate profitability metrics
roe = None
if (financial_record.net_income and financial_record.total_equity and
financial_record.total_equity > 0):
roe = financial_record.net_income / financial_record.total_equity
roa = None
if (financial_record.net_income and financial_record.total_assets and
financial_record.total_assets > 0):
roa = financial_record.net_income / financial_record.total_assets
gross_margin = None
if (financial_record.gross_profit and financial_record.revenue and
financial_record.revenue > 0):
gross_margin = financial_record.gross_profit / financial_record.revenue
operating_margin = None
if (financial_record.operating_income and financial_record.revenue and
financial_record.revenue > 0):
operating_margin = financial_record.operating_income / financial_record.revenue
net_margin = None
if (financial_record.net_income and financial_record.revenue and
financial_record.revenue > 0):
net_margin = financial_record.net_income / financial_record.revenue
# Calculate debt ratios
debt_to_equity = None
if (financial_record.total_debt and financial_record.total_equity and
financial_record.total_equity > 0):
debt_to_equity = financial_record.total_debt / financial_record.total_equity
debt_to_assets = None
if (financial_record.total_debt and financial_record.total_assets and
financial_record.total_assets > 0):
debt_to_assets = financial_record.total_debt / financial_record.total_assets
# Calculate cash flow metrics
ocf_margin = None
if (financial_record.operating_cash_flow and financial_record.revenue and
financial_record.revenue > 0):
ocf_margin = financial_record.operating_cash_flow / financial_record.revenue
fcf_margin = None
if (financial_record.free_cash_flow and financial_record.revenue and
financial_record.revenue > 0):
fcf_margin = financial_record.free_cash_flow / financial_record.revenue
# Create calculated metrics record
metrics = CalculatedMetrics(
ticker=ticker,
calculation_date=datetime.now(timezone.utc),
period_date=period_date,
pe_ratio=pe_ratio,
pb_ratio=pb_ratio,
ps_ratio=ps_ratio,
roe=roe,
roa=roa,
gross_margin=gross_margin,
operating_margin=operating_margin,
net_margin=net_margin,
debt_to_equity=debt_to_equity,
debt_to_assets=debt_to_assets,
ocf_margin=ocf_margin,
fcf_margin=fcf_margin,
market_cap=market_cap,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.add(metrics)
calculated_metrics.append(metrics)
if calculated_metrics:
await db.commit()
for metrics in calculated_metrics:
await db.refresh(metrics)
return calculated_metrics
def _find_price_near_date(self, price_data: List[PriceData], target_date: datetime) -> Optional[PriceData]:
"""Find price data closest to the target date"""
if not price_data:
return None
# Convert target_date to date for comparison
target_date_only = target_date.date()
closest_price = None
min_diff = float('inf')
for price in price_data:
price_date = price.date.date() if hasattr(price.date, 'date') else price.date
diff = abs((price_date - target_date_only).days)
if diff < min_diff:
min_diff = diff
closest_price = price
return closest_price
# Import pandas for data processing
try:
import pandas as pd
except ImportError:
logger.error("pandas not available - real SEC financial service will not work")
pd = None

@ -0,0 +1,41 @@
"""
Service for fetching and processing SEC data
"""
from datetime import datetime, timedelta
from typing import Dict, List, Optional
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
import sys
import os
# Add parent directory to path for imports
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from app.models.financial import Company, FinancialData, CalculatedMetrics
from app.schemas.financial import ErrorType, DataSource
from app.core.config import settings
from app.services.real_sec_financial_service import RealSECFinancialService
logger = logging.getLogger(__name__)
class SECDataService:
def __init__(self):
self.financial_service = RealSECFinancialService()
async def get_or_update_company_data(
self,
db: AsyncSession,
ticker: str,
start_date: datetime,
end_date: datetime,
force_refresh: bool = False
) -> Dict:
"""
Get company data using real financial service with price-based calculations
"""
# Use the real SEC financial service for actual SEC data
return await self.financial_service.get_or_create_company_data(
db, ticker, start_date, end_date, force_refresh
)

@ -0,0 +1,376 @@
"""
SEC EDGAR Direct API Service
Fetches real financial data directly from SEC EDGAR without dependencies
"""
import aiohttp
import asyncio
import json
from datetime import datetime, timezone, timedelta
from typing import Dict, List, Optional, Tuple, Any
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from app.models.financial import Company, FinancialData, CalculatedMetrics, PriceData
from app.schemas.financial import DataSource
logger = logging.getLogger(__name__)
class SECEdgarService:
"""Direct SEC EDGAR API service for financial data"""
def __init__(self):
self.sec_data_url = "https://data.sec.gov"
self.sec_www_url = "https://www.sec.gov"
self.headers = {
"User-Agent": "Stock Oracle API stockoracle@example.com",
"Accept": "application/json",
"Accept-Encoding": "gzip, deflate"
}
async def get_company_cik(self, ticker: str) -> Optional[str]:
"""Get company CIK from SEC ticker mapping"""
try:
url = f"{self.sec_www_url}/files/company_tickers.json"
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=self.headers) as response:
if response.status == 200:
data = await response.json()
# Search for ticker in the mapping
for key, company_info in data.items():
if company_info.get('ticker', '').upper() == ticker.upper():
cik_str = str(company_info.get('cik_str', '')).zfill(10)
logger.info(f"Found CIK {cik_str} for ticker {ticker}")
return cik_str
logger.warning(f"Ticker {ticker} not found in SEC mapping")
return None
else:
logger.error(f"Failed to fetch company tickers: {response.status}")
return None
except Exception as e:
logger.error(f"Error fetching CIK for {ticker}: {e}")
return None
async def get_company_facts(self, cik: str) -> Optional[Dict]:
"""Get company facts from SEC EDGAR API"""
try:
url = f"{self.sec_data_url}/api/xbrl/companyfacts/CIK{cik.zfill(10)}.json"
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=self.headers) as response:
if response.status == 200:
data = await response.json()
logger.info(f"Successfully fetched SEC facts for CIK {cik}")
return data
elif response.status == 404:
logger.warning(f"No SEC data found for CIK {cik}")
return None
else:
logger.error(f"SEC API error for CIK {cik}: {response.status}")
return None
except Exception as e:
logger.error(f"Error fetching company facts for CIK {cik}: {e}")
return None
def extract_financial_data(self, facts_data: Dict, start_date: datetime, end_date: datetime) -> List[Dict]:
"""Extract financial data from SEC facts"""
try:
if not facts_data or 'facts' not in facts_data:
return []
facts = facts_data['facts']
financial_records = []
# Common XBRL concepts mapping (without namespace prefix - it's already in the structure)
concept_mapping = {
# Revenue concepts
'Revenues': 'revenue',
'RevenueFromContractWithCustomerExcludingAssessedTax': 'revenue',
'SalesRevenueNet': 'revenue',
# Income concepts
'OperatingIncomeLoss': 'operating_income',
'NetIncomeLoss': 'net_income',
'GrossProfit': 'gross_profit',
# Balance sheet concepts
'Assets': 'total_assets',
'StockholdersEquity': 'total_equity',
'LiabilitiesAndStockholdersEquity': 'total_assets', # Alternative for total assets
'Liabilities': 'total_debt',
'CashAndCashEquivalentsAtCarryingValue': 'cash',
'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents': 'cash',
# Share data
'CommonStockSharesOutstanding': 'shares_outstanding',
'WeightedAverageNumberOfSharesOutstandingBasic': 'shares_outstanding',
'WeightedAverageNumberOfDilutedSharesOutstanding': 'shares_outstanding',
# Cash flow concepts
'NetCashProvidedByUsedInOperatingActivities': 'operating_cash_flow',
'PaymentsToAcquirePropertyPlantAndEquipment': 'capex'
}
# Collect all quarterly and annual data points
data_points = {}
# Access us-gaap namespace
us_gaap_facts = facts.get('us-gaap', {})
for concept, field_name in concept_mapping.items():
if concept in us_gaap_facts:
units = us_gaap_facts[concept].get('units', {})
# Try USD first, then shares for share counts
unit_key = 'USD' if 'USD' in units else ('shares' if 'shares' in units else None)
if unit_key and unit_key in units:
for entry in units[unit_key]:
# Get the period end date
end = entry.get('end')
if not end:
continue
try:
# Handle date format like '2016-09-24'
if 'T' not in end and 'Z' not in end:
period_date = datetime.strptime(end, '%Y-%m-%d')
period_date = period_date.replace(tzinfo=timezone.utc)
else:
period_date = datetime.fromisoformat(end.replace('Z', '+00:00'))
except Exception as e:
logger.warning(f"Could not parse date {end}: {e}")
continue
# Check if within date range
if period_date < start_date or period_date > end_date:
continue
# Get period info
form = entry.get('form', '')
filing_date = entry.get('filed', '')
value = entry.get('val')
if value is None:
continue
# Create period key (quarter end date)
period_key = period_date.strftime('%Y-%m-%d')
if period_key not in data_points:
data_points[period_key] = {
'period_date': period_date,
'form': form,
'filing_date': filing_date,
'period_type': 'quarterly' if form == '10-Q' else 'annual'
}
# Store the value
data_points[period_key][field_name] = float(value)
# Convert to financial records
for period_key, data in data_points.items():
if len(data) > 4: # Must have more than just metadata
financial_records.append(data)
# Sort by period date
financial_records.sort(key=lambda x: x['period_date'])
logger.info(f"Extracted {len(financial_records)} financial periods from SEC data")
return financial_records
except Exception as e:
logger.error(f"Error extracting financial data: {e}")
return []
async def get_financial_data(
self,
db: AsyncSession,
ticker: str,
start_date: datetime,
end_date: datetime,
force_refresh: bool = False
) -> List[FinancialData]:
"""Get financial data for a company from SEC EDGAR"""
ticker = ticker.upper()
# Check if we already have data
if not force_refresh:
existing_result = await db.execute(
select(FinancialData).where(
and_(
FinancialData.ticker == ticker,
FinancialData.period_date >= start_date,
FinancialData.period_date <= end_date,
FinancialData.data_source == DataSource.SEC_EDGAR.value,
FinancialData.is_estimated == False
)
).order_by(FinancialData.period_date)
)
existing_data = existing_result.scalars().all()
if existing_data:
logger.info(f"Found {len(existing_data)} existing SEC records for {ticker}")
return existing_data
# Get company CIK
cik = await self.get_company_cik(ticker)
if not cik:
logger.error(f"Could not find CIK for ticker {ticker}")
return []
# Get company facts from SEC
facts_data = await self.get_company_facts(cik)
if not facts_data:
logger.error(f"Could not fetch SEC facts for {ticker} (CIK: {cik})")
return []
# Extract financial data
financial_periods = self.extract_financial_data(facts_data, start_date, end_date)
if not financial_periods:
logger.warning(f"No financial data extracted for {ticker}")
return []
# Convert to database records
financial_records = []
for period_data in financial_periods:
try:
# Check if record already exists
period_date = period_data['period_date']
period_type = period_data.get('period_type', 'quarterly')
existing_result = await db.execute(
select(FinancialData).where(
and_(
FinancialData.ticker == ticker,
FinancialData.period_date == period_date,
FinancialData.period_type == period_type
)
)
)
existing_record = existing_result.scalar_one_or_none()
if existing_record and not force_refresh:
financial_records.append(existing_record)
continue
# Calculate EPS if we have net income and shares
eps = None
net_income = period_data.get('net_income')
shares_outstanding = period_data.get('shares_outstanding')
if net_income and shares_outstanding and shares_outstanding > 0:
eps = net_income / shares_outstanding
# Calculate free cash flow
free_cash_flow = None
operating_cash_flow = period_data.get('operating_cash_flow')
capex = period_data.get('capex')
if operating_cash_flow and capex:
free_cash_flow = operating_cash_flow - abs(capex) # capex is usually negative
if existing_record:
# Update existing record
existing_record.revenue = period_data.get('revenue')
existing_record.gross_profit = period_data.get('gross_profit')
existing_record.operating_income = period_data.get('operating_income')
existing_record.net_income = net_income
existing_record.eps = eps
existing_record.total_assets = period_data.get('total_assets')
existing_record.total_equity = period_data.get('total_equity')
existing_record.total_debt = period_data.get('total_debt')
existing_record.cash = period_data.get('cash')
existing_record.shares_outstanding = shares_outstanding
existing_record.operating_cash_flow = operating_cash_flow
existing_record.free_cash_flow = free_cash_flow
existing_record.capex = abs(capex) if capex else None
existing_record.data_source = DataSource.SEC_EDGAR.value
existing_record.is_estimated = False
existing_record.updated_at = datetime.now(timezone.utc)
financial_records.append(existing_record)
else:
# Create new record
filing_type = "10-K" if period_type == "annual" else "10-Q"
financial_record = FinancialData(
ticker=ticker,
period_date=period_date,
period_type=period_type,
filing_type=filing_type,
revenue=period_data.get('revenue'),
gross_profit=period_data.get('gross_profit'),
operating_income=period_data.get('operating_income'),
net_income=net_income,
eps=eps,
total_assets=period_data.get('total_assets'),
total_equity=period_data.get('total_equity'),
total_debt=period_data.get('total_debt'),
cash=period_data.get('cash'),
shares_outstanding=shares_outstanding,
operating_cash_flow=operating_cash_flow,
free_cash_flow=free_cash_flow,
capex=abs(capex) if capex else None,
data_source=DataSource.SEC_EDGAR.value,
is_estimated=False,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.add(financial_record)
financial_records.append(financial_record)
logger.info(f"Processed SEC data for {ticker} {period_date.date()}: Revenue=${period_data.get('revenue', 0):,.0f}")
except Exception as e:
logger.error(f"Error processing period data for {ticker}: {e}")
continue
if financial_records:
await db.commit()
# Refresh all records to get IDs
for record in financial_records:
if record.id is None: # Only refresh new records
await db.refresh(record)
logger.info(f"Successfully fetched {len(financial_records)} SEC financial records for {ticker}")
return financial_records
async def get_company_info(self, ticker: str) -> Dict[str, Any]:
"""Get company information from SEC"""
try:
cik = await self.get_company_cik(ticker)
if not cik:
return self._get_fallback_company_info(ticker)
facts_data = await self.get_company_facts(cik)
if not facts_data:
return self._get_fallback_company_info(ticker)
entity_info = facts_data.get('entityName', ticker)
return {
'name': entity_info,
'cik': cik,
'sector': 'Technology', # SEC doesn't provide sector info directly
'industry': 'Software',
'business_description': f'{entity_info} - SEC registered company'
}
except Exception as e:
logger.error(f"Error fetching SEC company info for {ticker}: {e}")
return self._get_fallback_company_info(ticker)
def _get_fallback_company_info(self, ticker: str) -> Dict[str, Any]:
"""Fallback company info"""
return {
'name': f'{ticker} Corporation',
'cik': f'000{hash(ticker) % 1000000:06d}',
'sector': 'Technology',
'industry': 'Software',
'business_description': f'{ticker} technology company'
}

@ -0,0 +1,304 @@
"""
Yahoo Finance 52-Week Gainers Service
야후 파이낸스에서 52주 상승률 상위 주식 데이터를 가져오는 서비스
"""
import logging
import re
import asyncio
import time
from typing import List, Dict, Optional
from datetime import datetime
import random
from curl_cffi import requests
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
class Yahoo52WeekGainersService:
"""Yahoo Finance 52-Week Gainers 서비스 with intelligent rate limiting"""
def __init__(self):
self.base_url = "https://finance.yahoo.com/markets/stocks/52-week-gainers/"
self.session = None
self._last_session_time = None
self._request_count = 0
self._last_request_time = 0
# Rate limiting 설정
self.min_delay = 1.0 # 최소 1초 대기
self.max_delay = 3.0 # 최대 3초 대기
self.batch_delay = 5.0 # 배치 간 대기시간
self.requests_per_batch = 3 # 배치당 요청 수
def _get_session(self) -> requests.Session:
"""세션 생성 및 관리"""
current_time = datetime.now()
# 세션이 없거나 5분 이상 지났으면 새로 생성 (더 자주 갱신)
if (self.session is None or
self._last_session_time is None or
(current_time - self._last_session_time).seconds > 300):
self.session = requests.Session(impersonate='chrome')
self._last_session_time = current_time
self._request_count = 0 # 리셋
logger.info("🔄 Created new Yahoo Finance session for 52-week gainers")
return self.session
async def _smart_delay(self, page_num: int = 0):
"""Intelligent rate limiting with progressive delays"""
current_time = time.time()
# 기본 지연시간 (랜덤)
base_delay = random.uniform(self.min_delay, self.max_delay)
# 배치 처리 지연
if self._request_count > 0 and self._request_count % self.requests_per_batch == 0:
batch_delay = self.batch_delay + random.uniform(0, 2.0)
logger.info(f"🛑 Batch delay: {batch_delay:.1f}s (after {self._request_count} requests)")
await asyncio.sleep(batch_delay)
else:
await asyncio.sleep(base_delay)
# 요청 간 최소 간격 보장
time_since_last = current_time - self._last_request_time
if time_since_last < self.min_delay:
additional_delay = self.min_delay - time_since_last
await asyncio.sleep(additional_delay)
self._last_request_time = time.time()
self._request_count += 1
# 페이지 번호에 따른 progressive delay
if page_num > 3:
extra_delay = (page_num - 3) * 0.5 # 4페이지부터 0.5초씩 추가
logger.debug(f"⏱️ Progressive delay for page {page_num}: +{extra_delay:.1f}s")
await asyncio.sleep(extra_delay)
async def get_total_count(self) -> int:
"""총 주식 개수 확인"""
session = self._get_session()
url = f'{self.base_url}?start=0&count=200'
try:
await self._smart_delay()
response = session.get(url, timeout=30)
if response.status_code == 200:
soup = BeautifulSoup(response.text, 'html.parser')
# 페이지네이션 정보 찾기
pagination_texts = soup.find_all(string=re.compile(r'\d+-\d+ of \d+'))
for text in pagination_texts:
match = re.search(r'(\d+)-(\d+) of (\d+)', text.strip())
if match:
total = int(match.group(3))
logger.info(f"📊 Total 52-week gainers available: {total}")
return total
except Exception as e:
logger.error(f"Error getting total count: {e}")
return 1350 # 기본값
async def get_page_stocks(self, start: int, count: int, page_num: int = 0) -> List[Dict]:
"""
특정 페이지의 52주 상승 주식 데이터를 가져옴
Args:
start: 시작 인덱스 (0, 200, 400, ...)
count: 가져올 주식 개수 (최대 200)
page_num: 페이지 번호 (rate limiting 용)
Returns:
주식 정보 리스트
"""
session = self._get_session()
url = f'{self.base_url}?start={start}&count={count}'
try:
await self._smart_delay(page_num)
logger.debug(f'📡 Fetching page {page_num + 1} (start={start})')
response = session.get(url, timeout=30)
if response.status_code != 200:
logger.warning(f'⚠️ HTTP {response.status_code} for start={start}')
return []
soup = BeautifulSoup(response.text, 'html.parser')
# 메인 테이블 찾기
table = soup.find('table')
if not table:
logger.warning('⚠️ No table found')
return []
tbody = table.find('tbody')
if not tbody:
logger.warning('⚠️ No tbody found')
return []
rows = tbody.find_all('tr')
logger.debug(f'📊 Found {len(rows)} data rows')
stocks = []
for row in rows:
try:
cells = row.find_all('td')
if len(cells) < 8: # 최소 8개 컬럼 필요
continue
# 데이터 추출 (most-active와 동일한 구조)
symbol_cell = cells[0]
symbol_link = symbol_cell.find('a')
if not symbol_link:
symbol = symbol_cell.text.strip()
else:
symbol = symbol_link.text.strip()
# 회사명
company_name = cells[1].text.strip() if len(cells) > 1 else "N/A"
# 가격 및 변화 정보
price_change_raw = cells[3].text.strip() if len(cells) > 3 else "N/A"
change_amount = cells[4].text.strip() if len(cells) > 4 else "N/A"
change_percent = cells[5].text.strip() if len(cells) > 5 else "N/A"
volume = cells[6].text.strip() if len(cells) > 6 else "N/A"
avg_volume = cells[7].text.strip() if len(cells) > 7 else "N/A"
# 52주 최고가 (8번째 컬럼이 있다면)
high_52w = cells[8].text.strip() if len(cells) > 8 else "N/A"
# 현재가 추출
current_price = "N/A"
if price_change_raw and price_change_raw != "N/A":
parts = price_change_raw.split()
if parts:
current_price = parts[0]
stock_data = {
'symbol': symbol,
'company_name': company_name,
'current_price': current_price,
'price_change_raw': price_change_raw,
'change_amount': change_amount,
'change_percent': change_percent,
'volume': volume,
'avg_volume': avg_volume,
'high_52w': high_52w,
'scraped_at': datetime.now().isoformat()
}
stocks.append(stock_data)
except Exception as e:
logger.debug(f'⚠️ Error processing row: {e}')
continue
logger.info(f'✅ Page {page_num + 1}: Extracted {len(stocks)} stocks from start={start}')
return stocks
except Exception as e:
logger.error(f'❌ Error fetching page data (start={start}): {e}')
# Rate limit에 걸렸을 가능성이 있으면 추가 대기
if 'rate' in str(e).lower() or 'limit' in str(e).lower():
logger.warning(f'🚫 Possible rate limiting detected, adding extra delay')
await asyncio.sleep(10.0)
return []
async def get_all_52week_gainers(self, limit: Optional[int] = None, max_pages: Optional[int] = None) -> Dict:
"""
모든 페이지에서 52주 상승 주식 데이터를 가져옴
Args:
limit: 최대 반환할 주식 개수 (None이면 전체)
max_pages: 최대 페이지 수 제한 (rate limiting 방지)
Returns:
결과 딕셔너리
"""
try:
logger.info('🔍 Starting Yahoo Finance 52-week gainers collection')
start_time = time.time()
total_count = await self.get_total_count()
all_stocks = []
page_size = 200
pages_needed = (total_count + page_size - 1) // page_size
# max_pages 제한 적용
if max_pages:
pages_needed = min(pages_needed, max_pages)
logger.info(f'📄 Limited to {max_pages} pages (of {(total_count + page_size - 1) // page_size} total)')
logger.info(f'📄 Fetching {pages_needed} pages for up to {total_count} total stocks')
for page in range(pages_needed):
start = page * page_size
logger.info(f' 📄 Processing page {page + 1}/{pages_needed} (start={start})')
page_stocks = await self.get_page_stocks(start, page_size, page)
if page_stocks:
all_stocks.extend(page_stocks)
logger.info(f' ✅ Added {len(page_stocks)} stocks (total: {len(all_stocks)})')
else:
logger.warning(f' ⚠️ No data from page {page + 1}')
# limit 적용
if limit and len(all_stocks) >= limit:
all_stocks = all_stocks[:limit]
logger.info(f'🎯 Reached limit of {limit} stocks, stopping')
break
elapsed_time = time.time() - start_time
logger.info(f'🎯 Final result: {len(all_stocks)} stocks collected in {elapsed_time:.1f}s')
return {
'success': True,
'data': {
'stocks': all_stocks,
'total_available': total_count,
'returned_count': len(all_stocks),
'pages_fetched': min(page + 1, pages_needed) if 'page' in locals() else pages_needed,
'scraped_at': datetime.now().isoformat(),
'elapsed_time_seconds': round(elapsed_time, 1)
},
'metadata': {
'source': 'finance.yahoo.com',
'endpoint': 'markets/stocks/52-week-gainers',
'method': 'intelligent_web_scraping',
'rate_limit_bypass': 'curl_cffi_chrome_impersonation_with_smart_delays',
'requests_made': self._request_count
}
}
except Exception as e:
logger.error(f'❌ Error in get_all_52week_gainers: {e}')
return {
'success': False,
'error': str(e),
'data': None
}
async def get_52week_gainers(self, limit: int = 200, max_pages: int = 3) -> Dict:
"""
상위 N개의 52주 상승 주식 데이터를 가져옴 (기본적으로 안전한 제한)
Args:
limit: 반환할 주식 개수
max_pages: 최대 페이지 수 (기본 3페이지 = 600개 주식)
Returns:
결과 딕셔너리
"""
return await self.get_all_52week_gainers(limit=limit, max_pages=max_pages)
# 싱글톤 인스턴스
yahoo_52week_gainers_service = Yahoo52WeekGainersService()

@ -0,0 +1,237 @@
"""
Yahoo Finance Most Active Stocks Service
야후 파이낸스에서 가장 활발한 주식 데이터를 가져오는 서비스
"""
import logging
import re
from typing import List, Dict, Optional
from datetime import datetime
from curl_cffi import requests
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
class YahooMostActiveService:
"""Yahoo Finance Most Active Stocks 서비스"""
def __init__(self):
self.base_url = "https://finance.yahoo.com/markets/stocks/most-active/"
self.session = None
self._last_session_time = None
def _get_session(self) -> requests.Session:
"""세션 생성 및 관리"""
current_time = datetime.now()
# 세션이 없거나 10분 이상 지났으면 새로 생성
if (self.session is None or
self._last_session_time is None or
(current_time - self._last_session_time).seconds > 600):
self.session = requests.Session(impersonate='chrome')
self._last_session_time = current_time
logger.info("🔄 Created new Yahoo Finance session")
return self.session
def get_total_count(self) -> int:
"""총 주식 개수 확인"""
session = self._get_session()
url = f'{self.base_url}?start=0&count=100'
try:
response = session.get(url, timeout=30)
if response.status_code == 200:
soup = BeautifulSoup(response.text, 'html.parser')
# 페이지네이션 정보 찾기
pagination_texts = soup.find_all(string=re.compile(r'\d+-\d+ of \d+'))
for text in pagination_texts:
match = re.search(r'(\d+)-(\d+) of (\d+)', text.strip())
if match:
total = int(match.group(3))
logger.info(f"📊 Total active stocks available: {total}")
return total
except Exception as e:
logger.error(f"Error getting total count: {e}")
return 168 # 기본값
def get_page_stocks(self, start: int, count: int) -> List[Dict]:
"""
특정 페이지의 주식 데이터를 가져옴
Args:
start: 시작 인덱스 (0, 100, 200, ...)
count: 가져올 주식 개수 (최대 100)
Returns:
주식 정보 리스트
"""
session = self._get_session()
url = f'{self.base_url}?start={start}&count={count}'
try:
response = session.get(url, timeout=30)
logger.debug(f'📡 HTTP Status: {response.status_code} for start={start}')
if response.status_code != 200:
return []
soup = BeautifulSoup(response.text, 'html.parser')
# 메인 테이블 찾기
table = soup.find('table')
if not table:
logger.warning('⚠️ No table found')
return []
tbody = table.find('tbody')
if not tbody:
logger.warning('⚠️ No tbody found')
return []
rows = tbody.find_all('tr')
logger.debug(f'📊 Found {len(rows)} data rows')
stocks = []
for row in rows:
try:
cells = row.find_all('td')
if len(cells) < 6: # 최소 6개 컬럼 필요
continue
# 심볼 (첫 번째 컬럼)
symbol_cell = cells[0]
symbol_link = symbol_cell.find('a')
if not symbol_link:
symbol = symbol_cell.text.strip()
else:
symbol = symbol_link.text.strip()
# 회사명 (두 번째 컬럼)
company_name = cells[1].text.strip() if len(cells) > 1 else "N/A"
# 가격 및 변화 정보 (네 번째 컬럼에 모든 가격 정보가 있음)
price_change_raw = cells[3].text.strip() if len(cells) > 3 else "N/A"
change_raw = cells[4].text.strip() if len(cells) > 4 else "N/A"
change_pct_raw = cells[5].text.strip() if len(cells) > 5 else "N/A"
volume_raw = cells[6].text.strip() if len(cells) > 6 else "N/A"
avg_volume_raw = cells[7].text.strip() if len(cells) > 7 else "N/A"
# 가격 정보 파싱 (예: "181.96 +0.42 (+0.23%)")
current_price = "N/A"
if price_change_raw and price_change_raw != "N/A":
parts = price_change_raw.split()
if parts:
current_price = parts[0]
# 구조화된 데이터 생성
stock_data = {
'symbol': symbol,
'company_name': company_name,
'current_price': current_price,
'price_change_raw': price_change_raw,
'change_amount': change_raw,
'change_percent': change_pct_raw,
'volume': volume_raw,
'avg_volume': avg_volume_raw,
'scraped_at': datetime.now().isoformat()
}
stocks.append(stock_data)
except Exception as e:
logger.debug(f'⚠️ Error processing row: {e}')
continue
logger.info(f'✅ Successfully extracted {len(stocks)} stocks from start={start}')
return stocks
except Exception as e:
logger.error(f'❌ Error fetching page data (start={start}): {e}')
return []
async def get_all_most_active_stocks(self, limit: Optional[int] = None) -> Dict:
"""
모든 페이지에서 활발한 주식 데이터를 가져옴
Args:
limit: 최대 반환할 주식 개수 (None이면 전체)
Returns:
결과 딕셔너리
"""
try:
logger.info('🔍 Starting Yahoo Finance most active stocks collection')
total_count = self.get_total_count()
all_stocks = []
page_size = 100
pages_needed = (total_count + page_size - 1) // page_size
logger.info(f'📄 Fetching {pages_needed} pages for {total_count} total stocks')
for page in range(pages_needed):
start = page * page_size
logger.debug(f' Page {page + 1}/{pages_needed} (start={start})')
page_stocks = self.get_page_stocks(start, page_size)
if page_stocks:
all_stocks.extend(page_stocks)
logger.debug(f' ✅ Added {len(page_stocks)} stocks (total: {len(all_stocks)})')
else:
logger.warning(f' ⚠️ No data from page {page + 1}')
# limit 적용
if limit and len(all_stocks) >= limit:
all_stocks = all_stocks[:limit]
break
logger.info(f'🎯 Final result: {len(all_stocks)} stocks collected')
return {
'success': True,
'data': {
'stocks': all_stocks,
'total_available': total_count,
'returned_count': len(all_stocks),
'pages_fetched': min(page + 1, pages_needed) if 'page' in locals() else pages_needed,
'scraped_at': datetime.now().isoformat()
},
'metadata': {
'source': 'finance.yahoo.com',
'endpoint': 'markets/stocks/most-active',
'method': 'web_scraping',
'rate_limit_bypass': 'curl_cffi_chrome_impersonation'
}
}
except Exception as e:
logger.error(f'❌ Error in get_all_most_active_stocks: {e}')
return {
'success': False,
'error': str(e),
'data': None
}
async def get_most_active_stocks(self, limit: int = 100) -> Dict:
"""
상위 N개의 활발한 주식 데이터를 가져옴
Args:
limit: 반환할 주식 개수
Returns:
결과 딕셔너리
"""
return await self.get_all_most_active_stocks(limit=limit)
# 싱글톤 인스턴스
yahoo_most_active_service = YahooMostActiveService()

@ -0,0 +1,3 @@
"""
Utility functions for Stock Oracle
"""

@ -0,0 +1,138 @@
"""
Redis-backed response caching utilities.
Design goals:
- Async Redis client with graceful degradation when Redis is unavailable
- Stable cache key builder
- JSON storage with ETag computation
- Simple helpers for endpoints to get/set cached responses
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional, Tuple
import hashlib
import asyncio
import orjson
logger = logging.getLogger(__name__)
try:
from redis import asyncio as aioredis
except Exception: # pragma: no cover - library import safety
aioredis = None # type: ignore
from app.core.config import settings
_redis_client: Optional["aioredis.Redis"] = None
_redis_lock = asyncio.Lock()
def _serialize(obj: Any) -> bytes:
"""Serialize Python object to JSON bytes using orjson."""
return orjson.dumps(obj)
def _deserialize(data: Optional[bytes]) -> Optional[Dict[str, Any]]:
if not data:
return None
try:
return orjson.loads(data)
except Exception as e:
logger.debug("Cache deserialization failed: %s", e)
return None
def compute_etag(payload_bytes: bytes) -> str:
"""Compute strong ETag for given payload bytes."""
return hashlib.sha256(payload_bytes).hexdigest()
async def get_redis() -> Optional["aioredis.Redis"]:
"""Get a shared async Redis client. Returns None if Redis unavailable."""
global _redis_client
if aioredis is None:
return None
if _redis_client is not None:
return _redis_client
async with _redis_lock:
if _redis_client is not None:
return _redis_client
try:
_redis_client = aioredis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=False)
# Light-touch ping to verify connectivity (do not raise)
try:
await _redis_client.ping()
except Exception as e:
logger.debug("Redis ping failed: %s", e)
pass
return _redis_client
except Exception as e:
logger.debug("Redis connection failed: %s", e)
return None
def build_cache_key(namespace: str, *parts: Any) -> str:
"""Build a stable cache key using namespace and parts.
Each part is converted to string and stripped. Empty parts are skipped.
"""
key_parts = [namespace]
for p in parts:
if p is None:
continue
s = str(p).strip()
if not s:
continue
key_parts.append(s)
return ":".join(key_parts)
async def get_cached_response(key: str) -> Optional[Tuple[Dict[str, Any], str]]:
"""Get cached response body and its ETag. Returns None if missing or on error.
The cached value is stored as JSON with shape: {"etag": str, "body": {...}}.
"""
client = await get_redis()
if client is None:
return None
try:
raw = await client.get(key)
data = _deserialize(raw)
if not data or "body" not in data:
return None
etag = data.get("etag")
# If etag missing, compute it from body
if not etag:
etag = compute_etag(_serialize(data["body"]))
return data["body"], etag
except Exception as e:
logger.debug("Cache get failed for key: %s", e)
return None
async def set_cached_response(key: str, body: Dict[str, Any], ttl_seconds: Optional[int] = None) -> str:
"""Cache response body with ETag. Returns the computed ETag.
If Redis is unavailable, this function is a no-op and returns the ETag anyway.
"""
payload_bytes = _serialize(body)
etag = compute_etag(payload_bytes)
record = {"etag": etag, "body": body}
client = await get_redis()
if client is None:
return etag
try:
if ttl_seconds is None:
ttl_seconds = max(60, int(getattr(settings, "CACHE_TTL", 3600)))
await client.set(key, _serialize(record), ex=ttl_seconds)
except Exception as e:
logger.debug("Cache set failed: %s", e)
return etag

@ -0,0 +1,329 @@
"""
Date utility functions for quarter handling and time parameter resolution
"""
from datetime import datetime, timezone, timedelta, date
from typing import Callable, List, Tuple, Optional, Union
import re
def quarter_to_date_range(quarter: str) -> Tuple[datetime, datetime]:
"""
Convert quarter string to start and end dates
Args:
quarter: Quarter in format YYYYQN (e.g., "2020Q1")
Returns:
Tuple of (start_date, end_date) as timezone-aware datetime objects
Raises:
ValueError: If quarter format is invalid
"""
if not re.match(r'^\d{4}Q[1-4]$', quarter):
raise ValueError(f"Invalid quarter format: {quarter}. Expected format: YYYYQN (e.g., 2020Q1)")
year = int(quarter[:4])
quarter_num = int(quarter[5])
# Quarter date mappings
quarter_dates = {
1: (1, 1, 3, 31), # Q1: Jan 1 - Mar 31
2: (4, 1, 6, 30), # Q2: Apr 1 - Jun 30
3: (7, 1, 9, 30), # Q3: Jul 1 - Sep 30
4: (10, 1, 12, 31) # Q4: Oct 1 - Dec 31
}
start_month, start_day, end_month, end_day = quarter_dates[quarter_num]
start_date = datetime(year, start_month, start_day, tzinfo=timezone.utc)
end_date = datetime(year, end_month, end_day, 23, 59, 59, tzinfo=timezone.utc)
return start_date, end_date
def quarters_to_date_range(quarters: List[str]) -> Tuple[datetime, datetime]:
"""
Convert list of quarters to overall start and end dates
Args:
quarters: List of quarters in format YYYYQN
Returns:
Tuple of (earliest_start_date, latest_end_date)
Raises:
ValueError: If any quarter format is invalid
"""
if not quarters:
raise ValueError("Quarters list cannot be empty")
date_ranges = [quarter_to_date_range(q) for q in quarters]
start_dates = [dr[0] for dr in date_ranges]
end_dates = [dr[1] for dr in date_ranges]
return min(start_dates), max(end_dates)
def parse_quarter(quarter_str: str) -> Tuple[int, int]:
"""
Parse quarter string into year and quarter number
Args:
quarter_str: Quarter in format YYYYQN
Returns:
Tuple of (year, quarter_number)
Raises:
ValueError: If quarter format is invalid
"""
if not re.match(r'^\d{4}Q[1-4]$', quarter_str):
raise ValueError(f"Invalid quarter format: {quarter_str}")
year = int(quarter_str[:4])
quarter_num = int(quarter_str[5])
return year, quarter_num
def format_quarter(year: int, quarter: int) -> str:
"""
Format year and quarter number into quarter string
Args:
year: Year (e.g., 2020)
quarter: Quarter number (1-4)
Returns:
Quarter string in format YYYYQN
Raises:
ValueError: If quarter number is invalid
"""
if quarter not in [1, 2, 3, 4]:
raise ValueError(f"Quarter must be 1-4, got: {quarter}")
return f"{year}Q{quarter}"
def get_quarter_for_date(date: datetime) -> str:
"""
Get quarter string for a given date
Args:
date: Date to convert
Returns:
Quarter string in format YYYYQN
"""
year = date.year
month = date.month
if month <= 3:
quarter = 1
elif month <= 6:
quarter = 2
elif month <= 9:
quarter = 3
else:
quarter = 4
return format_quarter(year, quarter)
def expand_quarter_range(start_quarter: str, end_quarter: str) -> List[str]:
"""
Expand quarter range into list of quarters
Args:
start_quarter: Starting quarter (e.g., "2020Q1")
end_quarter: Ending quarter (e.g., "2020Q4")
Returns:
List of quarters between start and end (inclusive)
Example:
expand_quarter_range("2020Q2", "2021Q1") -> ["2020Q2", "2020Q3", "2020Q4", "2021Q1"]
"""
start_year, start_q = parse_quarter(start_quarter)
end_year, end_q = parse_quarter(end_quarter)
quarters = []
year = start_year
quarter = start_q
while year < end_year or (year == end_year and quarter <= end_q):
quarters.append(format_quarter(year, quarter))
quarter += 1
if quarter > 4:
quarter = 1
year += 1
return quarters
def parse_period(period: str) -> Tuple[datetime, datetime]:
"""
Parse period string to start and end dates
Args:
period: Period string like "1d", "2m", "3y", "5d", "1m", "2y", "max"
Returns:
Tuple of (start_date, end_date) as timezone-aware datetime objects
Raises:
ValueError: If period format is invalid
Supported formats:
- Nd: N days (e.g., "1d", "7d", "30d")
- Nm: N months (e.g., "1m", "3m", "6m")
- Ny: N years (e.g., "1y", "2y", "5y")
- max: Maximum 20 years of historical data
"""
if not period or not isinstance(period, str):
raise ValueError("Period must be a non-empty string")
# Handle "max" period - get maximum 20 years of data
if period.lower() == "max":
# For max period, limit to 20 years to prevent excessive data requests
end_date = datetime.now(timezone.utc).replace(hour=23, minute=59, second=59, microsecond=0)
start_date = end_date - timedelta(days=20 * 365.25) # 20 years
return start_date, end_date
# Match pattern: number + unit (d/m/y)
match = re.match(r'^(\d+)([dmy])$', period.lower())
if not match:
raise ValueError(f"Invalid period format: {period}. Expected format: Nd/Nm/Ny/max (e.g., 1d, 3m, 2y, max)")
amount = int(match.group(1))
unit = match.group(2)
if amount <= 0:
raise ValueError(f"Period amount must be positive, got: {amount}")
# Calculate end date (yesterday to ensure data availability)
# Since today's data might not be available, use yesterday as end date
end_date = datetime.now(timezone.utc) - timedelta(days=1)
# Set to end of day (23:59:59)
end_date = end_date.replace(hour=23, minute=59, second=59, microsecond=0)
# Calculate start date based on unit
if unit == 'd':
# Days
start_date = end_date - timedelta(days=amount)
elif unit == 'm':
# Months (approximate using 30.44 days per month)
days_in_months = amount * 30.44
start_date = end_date - timedelta(days=days_in_months)
elif unit == 'y':
# Years (approximate using 365.25 days per year)
days_in_years = amount * 365.25
start_date = end_date - timedelta(days=days_in_years)
else:
raise ValueError(f"Unsupported unit: {unit}")
return start_date, end_date
def validate_period_format(period: str) -> bool:
"""
Validate if period string has correct format
Args:
period: Period string to validate
Returns:
True if valid, False otherwise
"""
try:
parse_period(period)
return True
except ValueError:
return False
def period_to_description(period: str) -> str:
"""
Convert period string to human-readable description
Args:
period: Period string like "1d", "3m", "2y", "max"
Returns:
Human-readable description
Example:
period_to_description("1d") -> "1 day"
period_to_description("3m") -> "3 months"
period_to_description("2y") -> "2 years"
period_to_description("max") -> "Maximum 20 years of data"
"""
if period.lower() == "max":
return "Maximum 20 years of data"
match = re.match(r'^(\d+)([dmy])$', period.lower())
if not match:
return period
amount = int(match.group(1))
unit = match.group(2)
unit_names = {
'd': 'day' if amount == 1 else 'days',
'm': 'month' if amount == 1 else 'months',
'y': 'year' if amount == 1 else 'years'
}
return f"{amount} {unit_names.get(unit, unit)}"
def resolve_time_parameters(
start_date: Optional[Union[date, datetime]] = None,
end_date: Optional[Union[date, datetime]] = None,
quarters: Optional[List[str]] = None,
period: Optional[str] = None,
ticker: Optional[str] = None,
ticker_max_range_fn: Optional[Callable[[str], Tuple[datetime, datetime]]] = None
) -> Tuple[datetime, datetime]:
"""
Resolve different time parameter approaches into start and end datetimes.
Args:
start_date: Start date
end_date: End date
quarters: List of quarters like ['2024Q1', '2024Q2']
period: Period string like '1d', '3m', '2y', 'max'
ticker: Ticker symbol (used for max period)
ticker_max_range_fn: Optional callback to resolve max range for a ticker
Returns:
Tuple of (start_datetime, end_datetime) with timezone info
"""
if period:
if period.lower() == "max" and ticker and ticker_max_range_fn:
return ticker_max_range_fn(ticker)
else:
start_dt, end_dt = parse_period(period)
return start_dt, end_dt
elif quarters:
start_dt, end_dt = quarters_to_date_range(quarters)
return start_dt, end_dt
elif start_date and end_date:
if isinstance(start_date, date) and not isinstance(start_date, datetime):
start_dt = datetime.combine(start_date, datetime.min.time(), timezone.utc)
else:
start_dt = start_date.replace(tzinfo=timezone.utc) if start_date.tzinfo is None else start_date
if isinstance(end_date, date) and not isinstance(end_date, datetime):
end_dt = datetime.combine(end_date, datetime.max.time(), timezone.utc)
else:
end_dt = end_date.replace(tzinfo=timezone.utc) if end_date.tzinfo is None else end_date
return start_dt, end_dt
else:
raise ValueError("One of period, quarters, or start_date/end_date must be provided")

@ -0,0 +1,90 @@
services:
# PostgreSQL Database
postgres:
image: postgres:15-alpine
container_name: stock_oracle_postgres
environment:
POSTGRES_USER: stockoracle
POSTGRES_PASSWORD: stockoracle2024
POSTGRES_DB: stock_oracle
ports:
- "15433:5432" # Unique port to avoid conflicts
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/init_db.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U stockoracle -d stock_oracle"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# Redis Cache
redis:
image: redis:7-alpine
container_name: stock_oracle_cache
command: redis-server --appendonly yes
ports:
- "16380:6379" # Unique port to avoid conflicts
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# FastAPI Application
api:
build:
context: .
dockerfile: Dockerfile
container_name: stock_oracle_api
environment:
- DATABASE_URL=postgresql+asyncpg://stockoracle:stockoracle2024@postgres:5432/stock_oracle
- REDIS_URL=redis://redis:6379/0
- ENVIRONMENT=development
- DEBUG=True
- API_PORT=18000
- SEC_EMAIL=example@example.com
ports:
- "18001:18000" # External:Internal port mapping
depends_on:
- postgres
- redis
volumes:
- ./app:/app/app # Mount app directory for development
- ./stock_oracle_analyzer.py:/app/stock_oracle_analyzer.py
- ./API_DOCUMENTATION.md:/app/API_DOCUMENTATION.md # API documentation
- ./yfinance_plus:/app/yfinance_plus # Mount yfinance_plus for development
- ./data:/app/data # For data files
restart: unless-stopped
command: ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "18000", "--reload"]
# Frontend Application
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: stock_oracle_frontend
environment:
- NODE_ENV=development
- NEXT_PUBLIC_API_URL=http://localhost:18001/api/v1
ports:
- "18002:3000" # External:Internal port mapping
volumes:
- ./frontend:/app # Mount entire frontend directory for development
- /app/node_modules # Exclude node_modules from mount
depends_on:
- api
restart: unless-stopped
command: ["sh", "-c", "npm install && npm run dev"]
volumes:
postgres_data:
redis_data:
networks:
default:
name: stock_oracle_network

@ -0,0 +1,124 @@
> NOTE: ETF API has been deprecated and removed. This document remains only as historical reference for how historical ETF holdings were previously fetched from SEC (NPORT/N-Q) and parsed. A new design will replace it.
## Key Features
### 1. Automatic CIK Resolution
The API automatically converts between ticker symbols and CIK numbers:
- Input ticker → Automatically finds CIK
- Input CIK → Returns data with associated ticker
- Unknown tickers → Attempts auto-lookup from SEC
### 2. Date Validation
The API validates requested dates against ETF launch dates:
- Returns error if ETF didn't exist on requested date
- Provides ETF launch date and first available NPORT date
- Automatically finds closest available data when possible
### 3. Performance Optimization
- Launch date caching for instant validation (<0.02s)
- Intelligent date range searching
- Compressed responses when holdings not needed
### 4. Availability Information
All error responses include `availability` field with:
- `exists_for_date`: Whether ETF existed on requested date
- `etf_launch_date`: When the ETF was launched
- `first_nport_date`: First available NPORT filing date
- `available_date_range`: Start and end dates of available data
- `days_before_launch`: How many days before ETF launch (if applicable)
## Data Sources
- **Primary Source**: SEC EDGAR NPORT-P filings
- **Filing Frequency**:
- Monthly filings (published quarterly) from 2019
- Quarterly filings before 2019 (N-Q forms)
- **Data Availability**: Generally 2019 onwards for most ETFs
- **Update Frequency**: New filings typically available 60 days after period end
## Supported ETFs
### Major Fund Families
| Fund Family | Example Tickers | CIK |
|------------|----------------|-----|
| Invesco | QQQ, QQQM, XLG | Various |
| SPDR | SPY, XLF, XLE, XLK | 884394, 1064641 |
| iShares | IWM, EFA, EEM, MTUM | 1100663 |
| Vanguard | VTI, VOO, VEA, VWO | 851229 |
| ARK | ARKK, ARKQ, ARKW | 1679090 |
### Auto-Lookup Support
ETFs not in the pre-configured list will be automatically looked up from SEC data.
## Rate Limits
- No hard rate limits for local deployment
- SEC EDGAR has rate limits (10 requests/second)
- Cached responses bypass SEC limits
## Error Codes
| Status | Description |
|--------|-------------|
| 200 | Success or data validation error with availability info |
| 400 | Invalid request parameters |
| 404 | ETF ticker/CIK not found |
| 500 | Internal server error |
## Best Practices
1. **Check Availability First**: Use `include_holdings=false` to quickly check data availability
2. **Use Recent Dates**: NPORT data typically lags by 60 days
3. **Cache Responses**: Holdings data doesn't change for historical dates
4. **Handle Availability Info**: Parse the `availability` field to show users available date ranges
## Examples
### Python
```python
import requests
# Get current holdings
response = requests.get("http://localhost:18001/api/v1/etf/holdings/QQQ")
data = response.json()
if data["success"]:
print(f"Found {data['data']['holdings_count']} holdings")
for holding in data["data"]["holdings"][:5]:
print(f"- {holding['name']}: {holding['percentage']:.2f}%")
else:
print(f"Error: {data['error']}")
if data.get("availability"):
print(f"Available from: {data['availability']['available_date_range']['start']}")
```
### JavaScript
```javascript
// Get historical holdings
fetch('http://localhost:18001/api/v1/etf/holdings/SPY?as_of_date=2023-12-31')
.then(res => res.json())
.then(data => {
if (data.success) {
console.log(`Holdings as of ${data.as_of_date}`);
data.data.holdings.slice(0, 5).forEach(h => {
console.log(`- ${h.name}: ${h.percentage.toFixed(2)}%`);
});
} else {
console.error(data.error);
if (data.availability) {
console.log('Available range:', data.availability.available_date_range);
}
}
});
```
## Changelog
### Version 2.0 (Latest)
- Added `availability` field to all error responses
- Implemented ETF launch date validation
- Added automatic date range detection
- Performance optimization with launch date caching
- Response time improved from 35s to <0.1s for date validation
### Version 1.0
- Initial ETF holdings API
- Support for ticker and CIK lookup
- Historical NPORT data access

@ -0,0 +1,538 @@
# Stock Oracle Python Client Documentation
## Installation
### Option 1: Direct File Usage
```bash
# Copy the client file to your project
cp stock_oracle_client.py /path/to/your/project/
# Install dependencies
pip install requests python-dateutil
```
### Option 2: Install as Package (Future)
```bash
# Will be available on PyPI
pip install stock-oracle-client
```
### Option 3: Development Installation
```bash
# Clone the repository
git clone <repo-url>
cd stock-oracle
# Install client dependencies
pip install -r requirements-client.txt
# Run examples
python examples/client_usage.py
```
## Quick Start
```python
from stock_oracle_client import StockOracleClient
# Initialize client
client = StockOracleClient("http://localhost:18001")
# Get financial data
financial = client.get_financial_data("AAPL", period="1y")
print(f"Company: {financial['company']['name']}")
# Get price data
prices = client.get_price_data("MSFT", period="3m")
print(f"Latest close: ${prices['price_data'][-1]['close']:.2f}")
# Get ETF holdings
etf = client.get_etf_holdings("QQQ")
print(f"Holdings: {etf['data']['holdings_count']}")
```
## Core Features
### 1. Client Initialization
```python
from stock_oracle_client import StockOracleClient
# Basic initialization
client = StockOracleClient("http://localhost:18001")
# With options
client = StockOracleClient(
base_url="http://localhost:18001",
api_key=None, # For future authentication
timeout=30, # Request timeout in seconds
auto_retry=True, # Automatic retry on failure
max_retries=3 # Maximum retry attempts
)
```
### 2. Financial Data
```python
# Using period (recommended)
data = client.get_financial_data("AAPL", period="1y")
# Using date range
from datetime import date
data = client.get_financial_data(
"AAPL",
start_date=date(2023, 1, 1),
end_date=date(2023, 12, 31),
period_type=PeriodType.QUARTERLY
)
# Using quarters
data = client.get_financial_data(
"AAPL",
quarters=["2024Q1", "2024Q2"],
include_metrics=True
)
```
### 3. Price Data
```python
from stock_oracle_client import PriceInterval
# Daily prices for last year
prices = client.get_price_data(
"TSLA",
period="1y",
interval=PriceInterval.ONE_DAY
)
# Weekly prices for specific range
prices = client.get_price_data(
"TSLA",
start_date="2024-01-01",
end_date="2024-06-30",
interval=PriceInterval.ONE_WEEK
)
```
### 4. ETF Holdings
```python
# Current holdings
etf = client.get_etf_holdings("QQQ")
# Historical holdings
etf = client.get_etf_holdings("SPY", as_of_date="2023-12-31")
# Without detailed holdings (faster)
etf = client.get_etf_holdings("ARKK", include_holdings=False)
# With automatic fallback
try:
etf = client.get_etf_holdings_with_fallback("QQQM", "2020-01-01")
except ETFDataNotAvailableError as e:
print(f"Error: {e}")
if e.availability_info:
print(f"Available from: {e.availability_info['available_date_range']['start']}")
```
### 5. Bulk Operations
```python
# Bulk financial data
bulk_financial = client.get_bulk_financial_data(
["AAPL", "MSFT", "GOOGL"],
period="6m",
period_type=PeriodType.QUARTERLY
)
# Bulk price data
bulk_prices = client.get_bulk_price_data(
["TSLA", "NIO", "RIVN"],
period="1m",
interval=PriceInterval.ONE_DAY
)
# Bulk ETF holdings
bulk_etf = client.get_bulk_etf_holdings(
["QQQ", "SPY", "IWM"],
include_holdings=False # For performance
)
```
## Advanced Usage
### Error Handling
```python
from stock_oracle_client import (
StockOracleClient,
StockOracleAPIError,
ETFDataNotAvailableError
)
client = StockOracleClient("http://localhost:18001")
try:
# Try to get data
data = client.get_financial_data("INVALID")
except StockOracleAPIError as e:
print(f"API Error: {e}")
print(f"Status Code: {e.status_code}")
print(f"Response: {e.response_data}")
except ETFDataNotAvailableError as e:
print(f"ETF Data Not Available: {e}")
if e.availability_info:
print(f"Launch Date: {e.availability_info.get('etf_launch_date')}")
print(f"Available Range: {e.availability_info.get('available_date_range')}")
```
### Availability Checking
```python
# Check if ETF data is available
availability = client.check_etf_availability("QQQM", "2020-01-01")
if not availability.get('exists_for_date'):
print(f"ETF didn't exist on requested date")
print(f"Launch date: {availability.get('etf_launch_date')}")
print(f"First available: {availability['available_date_range']['start']}")
```
### Using Enums
```python
from stock_oracle_client import PriceInterval, PeriodType
# Price intervals
intervals = [
PriceInterval.ONE_MINUTE,
PriceInterval.FIVE_MINUTES,
PriceInterval.ONE_HOUR,
PriceInterval.ONE_DAY,
PriceInterval.ONE_WEEK,
PriceInterval.ONE_MONTH
]
# Period types
period_types = [
PeriodType.QUARTERLY,
PeriodType.ANNUAL,
PeriodType.ALL
]
```
### Utility Methods
```python
# Validate ticker
is_valid = client.validate_ticker("AAPL") # Returns True
is_valid = client.validate_ticker("INVALID123") # Returns False
# Get latest filing date
filing_date = client.get_latest_filing_date("MSFT")
# Search tickers (client-side)
results = client.search_tickers("AA") # Returns ["AAPL", ...]
# Get supported ETFs
supported = client.get_supported_etfs()
print(f"Total ETFs: {supported['total_etfs']}")
# Get data catalog
catalog = client.get_data_catalog()
```
## Convenience Functions
For quick one-off requests without creating a client instance:
```python
from stock_oracle_client import get_financial_data, get_price_data, get_etf_holdings
# Quick financial data
financial = get_financial_data("AAPL", period="1y")
# Quick price data
prices = get_price_data("TSLA", period="3m")
# Quick ETF holdings
etf = get_etf_holdings("QQQ", as_of_date="2024-01-01")
```
## Best Practices
### 1. Use Period Strings
Period strings are the most convenient way to specify time ranges:
- `"1d"`, `"5d"`, `"1m"`, `"3m"`, `"6m"`, `"1y"`, `"2y"`, `"5y"`, `"10y"`, `"ytd"`, `"max"`
### 2. Batch Requests
Use bulk endpoints when fetching data for multiple tickers:
```python
# Good - single bulk request
bulk_data = client.get_bulk_financial_data(["AAPL", "MSFT", "GOOGL"])
# Bad - multiple individual requests
data1 = client.get_financial_data("AAPL")
data2 = client.get_financial_data("MSFT")
data3 = client.get_financial_data("GOOGL")
```
### 3. Handle ETF Availability
Always check availability when requesting historical ETF data:
```python
def get_etf_data_safe(client, ticker, date):
try:
return client.get_etf_holdings(ticker, as_of_date=date)
except ETFDataNotAvailableError as e:
if e.availability_info:
# Try with earliest available date
start_date = e.availability_info['available_date_range']['start']
return client.get_etf_holdings(ticker, as_of_date=start_date)
raise
```
### 4. Use Session Reuse
The client automatically reuses HTTP sessions for better performance:
```python
# Good - reuse client instance
client = StockOracleClient("http://localhost:18001")
for ticker in tickers:
data = client.get_financial_data(ticker)
# Bad - create new client each time
for ticker in tickers:
client = StockOracleClient("http://localhost:18001")
data = client.get_financial_data(ticker)
```
### 5. Error Recovery
Enable auto-retry for better reliability:
```python
client = StockOracleClient(
"http://localhost:18001",
auto_retry=True,
max_retries=3
)
```
## Examples
### Complete Example: Portfolio Analysis
```python
from stock_oracle_client import StockOracleClient, PeriodType
from datetime import datetime, timedelta
def analyze_portfolio(tickers, client):
"""Analyze a portfolio of stocks and ETFs"""
results = {
'stocks': {},
'etfs': {},
'errors': []
}
for ticker in tickers:
try:
# Try as ETF first
etf_data = client.get_etf_holdings(ticker, include_holdings=False)
if etf_data['success']:
results['etfs'][ticker] = {
'type': 'ETF',
'holdings_count': etf_data['data']['holdings_count'],
'as_of_date': etf_data['as_of_date']
}
continue
except:
pass
try:
# Try as stock
financial = client.get_financial_data(
ticker,
period="1y",
period_type=PeriodType.QUARTERLY
)
if financial['financial_data']:
latest = financial['financial_data'][0]
results['stocks'][ticker] = {
'type': 'Stock',
'company': financial['company']['name'],
'latest_filing': latest['date'],
'pe_ratio': latest.get('metrics', {}).get('pe_ratio'),
'market_cap': latest.get('market_cap')
}
except Exception as e:
results['errors'].append({
'ticker': ticker,
'error': str(e)
})
return results
# Usage
client = StockOracleClient("http://localhost:18001")
portfolio = ["AAPL", "QQQ", "MSFT", "SPY", "TSLA", "ARKK"]
analysis = analyze_portfolio(portfolio, client)
print("Stocks:")
for ticker, data in analysis['stocks'].items():
print(f" {ticker}: {data['company']}")
print("\nETFs:")
for ticker, data in analysis['etfs'].items():
print(f" {ticker}: {data['holdings_count']} holdings")
```
### Data Export Example
```python
import pandas as pd
from stock_oracle_client import StockOracleClient
client = StockOracleClient("http://localhost:18001")
# Get data
financial = client.get_financial_data("AAPL", period="2y")
# Convert to DataFrame
df = pd.DataFrame(financial['financial_data'])
# Export to CSV
df.to_csv("aapl_financial_data.csv", index=False)
# Export to Excel
df.to_excel("aapl_financial_data.xlsx", index=False)
print(f"Exported {len(df)} records")
```
## Troubleshooting
### Common Issues
1. **Connection Error**
```python
# Check if API is running
try:
health = client.get_health()
except:
print("API is not accessible")
```
2. **ETF Data Not Available**
- Check availability first using `check_etf_availability()`
- Use `get_etf_holdings_with_fallback()` for automatic fallback
- Check the `availability_info` in the exception
3. **Rate Limiting**
- Add delays between requests if needed
- Use bulk endpoints for multiple tickers
- Enable caching on the server side
4. **Timeout Issues**
```python
# Increase timeout for large requests
client = StockOracleClient("http://localhost:18001", timeout=60)
```
## API Response Structure
### Financial Data Response
```json
{
"ticker": "AAPL",
"company": {
"name": "Apple Inc.",
"sector": "Technology",
"industry": "Consumer Electronics"
},
"financial_data": [
{
"date": "2024-03-31",
"revenue": 119575000000,
"net_income": 23636000000,
"metrics": {
"pe_ratio": 26.5,
"return_on_equity": 1.47,
"gross_margin": 0.455
}
}
]
}
```
### ETF Holdings Response
```json
{
"ticker": "QQQ",
"as_of_date": "2024-12-31",
"success": true,
"data": {
"filing_info": {
"filing_date": "2024-12-31",
"cik": "1067839",
"total_value": 250000000000,
"total_holdings": 102
},
"holdings": [
{
"name": "MICROSOFT CORP",
"cusip": "594918104",
"value": 25000000000,
"shares": 50000000,
"percentage": 10.0
}
],
"holdings_count": 102
},
"availability": null
}
```
### Error Response with Availability
```json
{
"ticker": "QQQM",
"as_of_date": "2020-01-01",
"success": false,
"error": "ETF QQQM did not exist on 2020-01-01",
"availability": {
"exists_for_date": false,
"etf_launch_date": "2020-10-13",
"first_nport_date": "2021-01-31",
"available_date_range": {
"start": "2021-01-31",
"end": "present"
}
}
}
```
## Version History
### v2.0.0 (Current)
- Added comprehensive ETF holdings support
- Improved error handling with custom exceptions
- Added availability checking for ETF data
- Bulk operations for all data types
- Enum support for intervals and period types
- Automatic retry logic
- Utility methods for validation and search
### v1.0.0
- Initial release
- Basic financial and price data retrieval
- Period string support
- Bulk financial data
## Support
For issues, questions, or contributions:
- GitHub Issues: [Report bugs or request features]
- Documentation: [API Documentation](../README.md)
- Examples: See `examples/` directory

@ -0,0 +1,287 @@
#!/usr/bin/env python3
"""
Stock Oracle Client Usage Examples
This script demonstrates how to use the Stock Oracle Python client
for various data retrieval tasks.
"""
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from stock_oracle_client import StockOracleClient, ETFDataNotAvailableError, PriceInterval, PeriodType
from datetime import datetime, date, timedelta
import json
def print_section(title):
"""Helper to print section headers"""
print("\n" + "="*60)
print(f" {title}")
print("="*60)
def example_basic_usage():
"""Basic client usage examples"""
print_section("BASIC USAGE")
# Initialize client
client = StockOracleClient("http://localhost:18001")
# Check API health
health = client.get_health()
print(f"✅ API Status: {health['status']}")
print(f" Version: {health.get('version', 'Unknown')}")
# Get financial data for Apple
print("\n📊 Financial Data (AAPL - Last Year):")
financial = client.get_financial_data("AAPL", period="1y")
print(f" Company: {financial['company']['name']}")
print(f" Sector: {financial['company'].get('sector', 'N/A')}")
print(f" Data points: {len(financial['financial_data'])}")
if financial['financial_data']:
latest = financial['financial_data'][0]
print(f" Latest filing: {latest['date']}")
if 'metrics' in latest:
metrics = latest['metrics']
print(f" P/E Ratio: {metrics.get('pe_ratio', 'N/A')}")
print(f" ROE: {metrics.get('return_on_equity', 'N/A'):.2%}" if metrics.get('return_on_equity') else " ROE: N/A")
# Get price data
print("\n📈 Price Data (AAPL - Last 3 Months):")
prices = client.get_price_data("AAPL", period="3m")
print(f" Data points: {len(prices['price_data'])}")
if prices['price_data']:
latest = prices['price_data'][-1]
print(f" Latest date: {latest['date']}")
print(f" Close: ${latest['close']:.2f}")
print(f" Volume: {latest['volume']:,}")
def example_etf_holdings():
"""ETF holdings examples with availability checking"""
print_section("ETF HOLDINGS")
client = StockOracleClient("http://localhost:18001")
# Get current ETF holdings
print("\n📊 Current Holdings (QQQ):")
etf = client.get_etf_holdings("QQQ", include_holdings=True)
if etf['success']:
print(f" As of: {etf['as_of_date']}")
print(f" Total holdings: {etf['data']['holdings_count']}")
print(f" Total value: ${etf['data']['filing_info']['total_value']:,.2f}")
# Show top 5 holdings
if etf['data']['holdings']:
print("\n Top 5 Holdings:")
for i, holding in enumerate(etf['data']['holdings'][:5], 1):
print(f" {i}. {holding['name']}: {holding['percentage']:.2f}%")
# Check availability for historical date
print("\n📅 Availability Check (QQQM on 2020-01-01):")
availability = client.check_etf_availability("QQQM", "2020-01-01")
if not availability.get('exists_for_date'):
print(f" ❌ ETF did not exist on 2020-01-01")
if availability.get('etf_launch_date'):
print(f" Launch date: {availability['etf_launch_date']}")
if availability.get('available_date_range'):
date_range = availability['available_date_range']
print(f" Available from: {date_range['start']} to {date_range['end']}")
# Try with fallback
print("\n🔄 ETF Holdings with Fallback (QQQM):")
try:
etf = client.get_etf_holdings_with_fallback("QQQM", "2020-01-01", include_holdings=False)
print(f" ✅ Got data for: {etf['as_of_date']}")
print(f" Holdings count: {etf['data']['holdings_count']}")
except ETFDataNotAvailableError as e:
print(f" ❌ No data available: {e}")
def example_bulk_operations():
"""Bulk data retrieval examples"""
print_section("BULK OPERATIONS")
client = StockOracleClient("http://localhost:18001")
# Bulk financial data
print("\n💼 Bulk Financial Data (Tech Giants):")
tickers = ["AAPL", "MSFT", "GOOGL", "AMZN", "META"]
bulk_financial = client.get_bulk_financial_data(
tickers,
period="6m",
period_type=PeriodType.QUARTERLY,
include_metrics=True
)
print(f" Requested: {len(bulk_financial['requested_tickers'])} tickers")
print(f" Successful: {len(bulk_financial['results'])}")
# Show summary for each ticker
for ticker in tickers:
if ticker in bulk_financial['results']:
data = bulk_financial['results'][ticker]
if data['financial_data']:
latest = data['financial_data'][0]
print(f" {ticker}: Latest filing {latest['date']}")
# Bulk ETF holdings
print("\n📊 Bulk ETF Holdings:")
etf_tickers = ["QQQ", "SPY", "ARKK", "IWM"]
bulk_etf = client.get_bulk_etf_holdings(etf_tickers, include_holdings=False)
print(f" Total ETFs: {bulk_etf['total']}")
print(f" Successful: {bulk_etf['successful']}")
for ticker, data in bulk_etf['results'].items():
if data.get('success'):
print(f" {ticker}: {data['data']['holdings_count']} holdings as of {data['as_of_date']}")
def example_advanced_queries():
"""Advanced query examples with date ranges and intervals"""
print_section("ADVANCED QUERIES")
client = StockOracleClient("http://localhost:18001")
# Specific date range query
print("\n📅 Date Range Query (Q1 2024):")
start_date = date(2024, 1, 1)
end_date = date(2024, 3, 31)
financial = client.get_financial_data(
"MSFT",
start_date=start_date,
end_date=end_date,
period_type=PeriodType.QUARTERLY
)
print(f" Period: {start_date} to {end_date}")
print(f" Data points: {len(financial['financial_data'])}")
# Different price intervals
print("\n⏰ Price Data with Different Intervals:")
intervals = [
(PriceInterval.ONE_DAY, "Daily"),
(PriceInterval.ONE_WEEK, "Weekly"),
(PriceInterval.ONE_MONTH, "Monthly")
]
for interval, label in intervals:
prices = client.get_price_data(
"TSLA",
period="3m",
interval=interval
)
print(f" {label}: {len(prices['price_data'])} data points")
# Historical ETF data
print("\n📜 Historical ETF Data (SPY - 1 year ago):")
one_year_ago = (datetime.now() - timedelta(days=365)).date()
try:
etf = client.get_etf_holdings("SPY", as_of_date=one_year_ago, include_holdings=False)
if etf['success']:
print(f" Date: {etf['as_of_date']}")
print(f" Holdings: {etf['data']['holdings_count']}")
print(f" Total value: ${etf['data']['filing_info']['total_value']:,.2f}")
except ETFDataNotAvailableError as e:
print(f" ❌ Data not available: {e}")
def example_error_handling():
"""Error handling examples"""
print_section("ERROR HANDLING")
client = StockOracleClient("http://localhost:18001", auto_retry=True, max_retries=2)
# Invalid ticker
print("\n❌ Invalid Ticker Test:")
try:
data = client.get_financial_data("INVALID123", period="1m")
except Exception as e:
print(f" Expected error: {e}")
# ETF that didn't exist on date
print("\n❌ ETF Before Launch Date:")
try:
etf = client.get_etf_holdings("ARKK", as_of_date="2010-01-01")
except ETFDataNotAvailableError as e:
print(f" Expected error: {e}")
if e.availability_info:
print(f" ETF launch date: {e.availability_info.get('etf_launch_date', 'Unknown')}")
# Ticker validation
print("\n✅ Ticker Validation:")
valid_tickers = ["AAPL", "MSFT", "INVALID"]
for ticker in valid_tickers:
is_valid = client.validate_ticker(ticker)
print(f" {ticker}: {'Valid ✅' if is_valid else 'Invalid ❌'}")
def example_utilities():
"""Utility functions and helper methods"""
print_section("UTILITY FUNCTIONS")
client = StockOracleClient("http://localhost:18001")
# Get supported ETFs
print("\n📋 Supported ETFs:")
supported = client.get_supported_etfs()
print(f" Total: {supported['total_etfs']} ETFs")
print(f" Examples: {', '.join(supported['supported_tickers'][:10])}...")
# Search tickers (client-side)
print("\n🔍 Ticker Search:")
queries = ["AA", "APP", "GO"]
for query in queries:
results = client.search_tickers(query)
print(f" '{query}': {results}")
# Get latest filing date
print("\n📅 Latest Filing Dates:")
tickers = ["AAPL", "MSFT", "GOOGL"]
for ticker in tickers:
filing_date = client.get_latest_filing_date(ticker)
print(f" {ticker}: {filing_date if filing_date else 'N/A'}")
# Get data catalog
print("\n📚 Data Catalog Sample:")
catalog = client.get_data_catalog()
if 'categories' in catalog:
for category, fields in list(catalog['categories'].items())[:2]:
print(f" {category}: {len(fields)} fields")
def main():
"""Run all examples"""
print("\n" + "="*60)
print(" STOCK ORACLE CLIENT EXAMPLES")
print("="*60)
print("\nMake sure the API is running at http://localhost:18001")
try:
# Run examples
example_basic_usage()
example_etf_holdings()
example_bulk_operations()
example_advanced_queries()
example_error_handling()
example_utilities()
print("\n" + "="*60)
print(" ✅ ALL EXAMPLES COMPLETED SUCCESSFULLY")
print("="*60)
except Exception as e:
print(f"\n❌ Error running examples: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

@ -0,0 +1,14 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.git
.gitignore
.next
.vercel

@ -0,0 +1,15 @@
# Stock Oracle Frontend Environment Variables
# API Configuration
NEXT_PUBLIC_API_URL=http://localhost:18001/api/v1
# Application Configuration
NEXT_PUBLIC_APP_NAME=Stock Oracle
NEXT_PUBLIC_APP_VERSION=1.0.0
# Analytics (optional)
NEXT_PUBLIC_GA_ID=
# Feature Flags
NEXT_PUBLIC_ENABLE_ANALYTICS=false
NEXT_PUBLIC_ENABLE_PWA=false

@ -0,0 +1,55 @@
# Stock Oracle Frontend Dockerfile
# Base image with Node.js
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
RUN npm ci --only=production
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Set environment variables for build
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV production
# Build the application
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
# Create a non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy the public folder
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
# Start the application
CMD ["node", "server.js"]

@ -0,0 +1,309 @@
# 📊 Stock Oracle Frontend
React + Next.js로 구축된 Stock Oracle 웹 프론트엔드입니다.
## ✨ 주요 기능
### 🏠 대시보드
- 실시간 시스템 상태 모니터링
- 데이터베이스 통계 요약
- 빠른 액션 버튼
- 데이터 품질 개요
### 🔍 데이터 조회
- 주식 종목별 재무 데이터 검색
- 분기별/연간/전체 기간 조회
- 실제 vs 추정 데이터 표시
- 재무 지표 계산 결과 포함
### 💾 DB 상태
- 데이터베이스 현황 실시간 모니터링
- 회사, 재무 데이터, 주가 데이터 통계
- 데이터 소스별 분포
- 데이터 품질 분석
### ⚙️ 설정
- API URL 설정
- 자동 새로고침 옵션
- UI 테마 선택
- 시스템 정보 확인
## 🚀 빠른 시작
### 로컬 개발 환경
```bash
# 의존성 설치
cd frontend
npm install
# 환경 변수 설정
cp .env.example .env.local
# 개발 서버 시작
npm run dev
```
웹 브라우저에서 `http://localhost:3000` 접속
### Docker로 실행
```bash
# 이미지 빌드
docker build -t stock-oracle-frontend .
# 컨테이너 실행
docker run -p 3000:3000 -e NEXT_PUBLIC_API_URL=http://localhost:18001/api/v1 stock-oracle-frontend
```
### Portainer로 전체 스택 배포
```bash
# 전체 스택 배포 (API + Frontend + DB + Nginx)
docker-compose -f portainer/docker-compose.full-stack.yml up -d
```
접속 URL: `https://localhost` (Nginx를 통한 통합 접속)
## 🛠️ 기술 스택
### Frontend Framework
- **Next.js 14** - React 메타 프레임워크
- **React 18** - UI 라이브러리
- **TypeScript** - 타입 안전성
### Styling & UI
- **Tailwind CSS** - 유틸리티 CSS 프레임워크
- **Lucide React** - 아이콘 라이브러리
- **Responsive Design** - 모바일 친화적 디자인
### Data Management
- **Axios** - HTTP 클라이언트
- **React Query** - 서버 상태 관리
- **SWR 패턴** - 데이터 페칭 전략
### Charts & Visualization
- **Recharts** - React 차트 라이브러리
- **Date-fns** - 날짜 처리
## 📁 프로젝트 구조
```
frontend/
├── components/ # 재사용 가능한 컴포넌트
│ ├── Layout.tsx # 메인 레이아웃
│ ├── StockQuery.tsx # 주식 데이터 조회
│ └── DatabaseStats.tsx # DB 상태 모니터링
├── lib/ # 유틸리티 및 설정
│ └── api.ts # API 클라이언트 및 타입
├── pages/ # Next.js 페이지
│ ├── index.tsx # 대시보드
│ ├── query.tsx # 데이터 조회
│ ├── database.tsx # DB 상태
│ └── settings.tsx # 설정
├── styles/ # 스타일시트
│ └── globals.css # 글로벌 CSS
└── public/ # 정적 파일
```
## 🔧 환경 변수
### .env.local 설정
```bash
# API 설정
NEXT_PUBLIC_API_URL=http://localhost:18001/api/v1
# 애플리케이션 설정
NEXT_PUBLIC_APP_NAME=Stock Oracle
NEXT_PUBLIC_APP_VERSION=1.0.0
# 기능 플래그
NEXT_PUBLIC_ENABLE_ANALYTICS=false
NEXT_PUBLIC_ENABLE_PWA=false
```
### 프로덕션 환경
```bash
# Docker 환경에서 자동 설정
NEXT_PUBLIC_API_URL=http://api:8000/api/v1
NODE_ENV=production
```
## 📊 API 연동
### API 클라이언트
`lib/api.ts`에서 모든 API 호출을 관리합니다:
```typescript
// 재무 데이터 조회
const data = await stockApi.getFinancialData({
ticker: 'AAPL',
start_date: '2024-01-01',
end_date: '2024-12-31',
period_type: 'quarterly',
include_metrics: true
});
// 데이터베이스 통계 조회
const stats = await stockApi.getDatabaseStats();
```
### 주요 API 엔드포인트
- `POST /api/v1/financial/data` - 재무 데이터 조회
- `POST /api/v1/price/data` - 주가 데이터 조회
- `GET /api/v1/database/stats` - DB 통계
- `GET /api/v1/tickers` - 사용 가능한 종목 목록
## 🎨 UI/UX 특징
### 반응형 디자인
- **Desktop First**: 대형 화면 우선 설계
- **Mobile Optimized**: 모바일 기기 완벽 지원
- **Tablet Friendly**: 태블릿 환경 최적화
### 사용자 경험
- **직관적 네비게이션**: 명확한 메뉴 구조
- **실시간 피드백**: 로딩 상태 및 에러 처리
- **데이터 시각화**: 차트와 그래프로 이해하기 쉬운 표현
### 접근성
- **키보드 네비게이션**: 키보드만으로 모든 기능 접근
- **Screen Reader**: 스크린 리더 지원
- **고대비 모드**: 시각적 접근성 고려
## 🔍 주요 컴포넌트
### Layout 컴포넌트
```typescript
// 전체 레이아웃 및 네비게이션
<Layout title="Stock Oracle - 대시보드">
{children}
</Layout>
```
### StockQuery 컴포넌트
```typescript
// 주식 데이터 조회 폼 및 결과 표시
<StockQuery />
```
### DatabaseStats 컴포넌트
```typescript
// 데이터베이스 통계 및 상태 모니터링
<DatabaseStats />
```
## 🧪 개발 도구
### 개발 서버
```bash
npm run dev # 개발 서버 시작
npm run build # 프로덕션 빌드
npm run start # 프로덕션 서버 시작
npm run lint # ESLint 실행
```
### 타입 체크
```bash
npx tsc --noEmit # TypeScript 타입 체크
```
## 🚀 배포
### Vercel 배포
```bash
# Vercel CLI로 배포
vercel --prod
```
### Docker 배포
```bash
# 이미지 빌드 및 배포
docker build -t stock-oracle-frontend .
docker run -p 3000:3000 stock-oracle-frontend
```
### Portainer 통합 배포
```bash
# 전체 스택 배포
./portainer/quick-deploy.sh
```
## 📱 브라우저 지원
- **Chrome** 90+
- **Firefox** 88+
- **Safari** 14+
- **Edge** 90+
## 🔒 보안 고려사항
### API 통신
- **HTTPS Only**: 프로덕션에서 HTTPS 강제
- **CORS 설정**: 적절한 CORS 정책
- **Rate Limiting**: API 호출 제한
### 데이터 보호
- **입력 검증**: 모든 사용자 입력 검증
- **XSS 방지**: React의 기본 XSS 보호
- **CSRF 방지**: SameSite 쿠키 설정
## 🐛 문제 해결
### 일반적인 문제
#### API 연결 실패
```bash
# API 서버 상태 확인
curl http://localhost:18001/api/v1/database/stats
# 환경 변수 확인
echo $NEXT_PUBLIC_API_URL
```
#### 빌드 실패
```bash
# 캐시 클리어
rm -rf .next node_modules
npm install
npm run build
```
#### Docker 실행 문제
```bash
# 포트 충돌 확인
netstat -tlnp | grep 3000
# 컨테이너 로그 확인
docker logs stock-oracle-frontend
```
## 📈 성능 최적화
### 이미지 최적화
- **Next.js Image**: 자동 이미지 최적화
- **WebP 지원**: 최신 이미지 포맷 사용
### 코드 분할
- **Dynamic Import**: 필요할 때만 컴포넌트 로드
- **Tree Shaking**: 사용하지 않는 코드 제거
### 캐싱 전략
- **Static Generation**: 정적 페이지 생성
- **API Cache**: React Query로 API 응답 캐싱
## 🤝 기여 가이드
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
## 📄 라이선스
MIT License - 자세한 내용은 LICENSE 파일을 참조하세요.

@ -0,0 +1,188 @@
import React, { useState, useEffect } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { TrendingUp, Database, BarChart3, Settings, AlertTriangle, Activity, Search, DollarSign } from 'lucide-react';
interface LayoutProps {
children: React.ReactNode;
title?: string;
}
const ClientLayout: React.FC<LayoutProps> = ({ children, title = 'Stock Oracle' }) => {
const router = useRouter();
const [currentTime, setCurrentTime] = useState<string>('');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
setCurrentTime(new Date().toLocaleString('ko-KR'));
const timer = setInterval(() => {
setCurrentTime(new Date().toLocaleString('ko-KR'));
}, 1000);
return () => clearInterval(timer);
}, []);
const navigation = [
{
name: '대시보드',
href: '/',
icon: TrendingUp,
current: router.pathname === '/',
},
{
name: '종목 조회',
href: '/stock',
icon: Search,
current: router.pathname === '/stock',
},
{
name: '데이터 조회',
href: '/query',
icon: BarChart3,
current: router.pathname === '/query',
},
{
name: 'FRED 경제데이터',
href: '/fred',
icon: DollarSign,
current: router.pathname === '/fred',
},
{
name: 'DB 상태',
href: '/database',
icon: Database,
current: router.pathname === '/database',
},
{
name: '시스템 로그',
href: '/logs',
icon: Activity,
current: router.pathname === '/logs',
},
{
name: '설정',
href: '/settings',
icon: Settings,
current: router.pathname === '/settings',
},
];
return (
<>
<Head>
<title>{title}</title>
<meta name="description" content="Stock Oracle - 주식 데이터 분석 플랫폼" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{/* Favicon and Icons */}
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="alternate icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
{/* Web App Manifest */}
<link rel="manifest" href="/manifest.json" />
{/* Theme Colors */}
<meta name="theme-color" content="#3b82f6" />
<meta name="msapplication-TileColor" content="#3b82f6" />
{/* Open Graph */}
<meta property="og:title" content="Stock Oracle" />
<meta property="og:description" content="Investment Data Analysis Platform using SEC filings" />
<meta property="og:type" content="website" />
<meta property="og:image" content="/apple-touch-icon.png" />
{/* Twitter Card */}
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Stock Oracle" />
<meta name="twitter:description" content="Investment Data Analysis Platform using SEC filings" />
<meta name="twitter:image" content="/apple-touch-icon.png" />
</Head>
<div className="min-h-screen bg-gray-50">
{/* 사이드바 */}
<div className="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg">
<div className="flex h-16 items-center justify-center border-b border-gray-200">
<div className="flex items-center space-x-2">
<TrendingUp className="h-8 w-8 text-blue-600" />
<h1 className="text-xl font-bold text-gray-900">Stock Oracle</h1>
</div>
</div>
<nav className="mt-6 px-3">
<div className="space-y-1">
{navigation.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.name}
href={item.href}
className={`group flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors ${
item.current
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<Icon
className={`mr-3 h-5 w-5 ${
item.current ? 'text-blue-700' : 'text-gray-400 group-hover:text-gray-500'
}`}
/>
{item.name}
</Link>
);
})}
</div>
</nav>
{/* 하단 정보 */}
<div className="absolute bottom-0 w-full p-4 border-t border-gray-200">
<div className="text-xs text-gray-500">
<p>Stock Oracle v1.0.0</p>
<p>Real-time Financial Data</p>
</div>
</div>
</div>
{/* 메인 콘텐츠 */}
<div className="pl-64">
{/* 헤더 */}
<header className="bg-white shadow-sm border-b border-gray-200">
<div className="px-6 py-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold text-gray-900">
{navigation.find(item => item.current)?.name || 'Stock Oracle'}
</h2>
<div className="flex items-center space-x-4">
{/* API 상태 표시 */}
<div className="flex items-center space-x-2">
<div className="h-2 w-2 bg-green-400 rounded-full"></div>
<span className="text-sm text-gray-600">API 연결됨</span>
</div>
{/* 현재 시간 */}
<div className="text-sm text-gray-500">
{mounted ? currentTime : '--:--:--'}
</div>
</div>
</div>
</div>
</header>
{/* 페이지 콘텐츠 */}
<main className="p-6">
<div className="max-w-7xl mx-auto">
{children}
</div>
</main>
</div>
</div>
</>
);
};
export default ClientLayout;

@ -0,0 +1,247 @@
import React, { useState, useEffect } from 'react';
import { Database, RefreshCw, BarChart3, TrendingUp, Calendar, AlertCircle } from 'lucide-react';
import { stockApi, DatabaseStats as DatabaseStatsType, formatNumber } from '@/lib/api';
const DatabaseStats: React.FC = () => {
const [stats, setStats] = useState<DatabaseStatsType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const fetchStats = async () => {
setLoading(true);
setError(null);
try {
const data = await stockApi.getDatabaseStats();
setStats(data);
setLastUpdated(new Date());
} catch (err) {
setError(err instanceof Error ? err.message : '데이터베이스 통계를 가져오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStats();
}, []);
const handleRefresh = () => {
fetchStats();
};
if (loading && !stats) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="spinner mx-auto mb-4"></div>
<p className="text-gray-600">데이터베이스 통계를 로딩 중...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<AlertCircle className="h-5 w-5 text-red-400 mr-2" />
<p className="text-red-800">{error}</p>
</div>
<button
onClick={handleRefresh}
className="px-3 py-1 bg-red-100 text-red-800 rounded-md hover:bg-red-200 text-sm"
>
다시 시도
</button>
</div>
</div>
);
}
if (!stats) return null;
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900 flex items-center">
<Database className="h-6 w-6 mr-2 text-blue-600" />
데이터베이스 현황
</h2>
<div className="flex items-center space-x-4">
{lastUpdated && (
<span className="text-sm text-gray-500">
마지막 업데이트: {lastUpdated.toLocaleString('ko-KR')}
</span>
)}
<button
onClick={handleRefresh}
disabled={loading}
className="flex items-center px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
새로고침
</button>
</div>
</div>
{/* 요약 카드들 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* 총 회사 수 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<TrendingUp className="h-8 w-8 text-blue-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">등록된 회사</p>
<p className="text-3xl font-bold text-gray-900">{formatNumber(stats.companies.total)}</p>
</div>
</div>
<div className="mt-4 text-sm text-gray-600">
<p>재무 데이터: {stats.companies.with_financial_data}개</p>
<p>주가 데이터: {stats.companies.with_price_data}개</p>
</div>
</div>
{/* 재무 데이터 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<BarChart3 className="h-8 w-8 text-green-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">재무 데이터</p>
<p className="text-3xl font-bold text-gray-900">{formatNumber(stats.financial_data.total_records)}</p>
</div>
</div>
<div className="mt-4 text-sm text-gray-600">
<p className="text-green-600">실제: {formatNumber(stats.financial_data.real_data)}</p>
<p className="text-yellow-600">추정: {formatNumber(stats.financial_data.estimated_data)}</p>
</div>
</div>
{/* 주가 데이터 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<TrendingUp className="h-8 w-8 text-purple-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">주가 데이터</p>
<p className="text-3xl font-bold text-gray-900">{formatNumber(stats.price_data.total_records)}</p>
</div>
</div>
<div className="mt-4 text-sm text-gray-600">
<p>종목 수: {stats.price_data.tickers.length}개</p>
</div>
</div>
{/* 계산된 지표 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<Calendar className="h-8 w-8 text-orange-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">계산된 지표</p>
<p className="text-3xl font-bold text-gray-900">{formatNumber(stats.calculated_metrics.total_records)}</p>
</div>
</div>
</div>
</div>
{/* 상세 정보 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 재무 데이터 상세 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">재무 데이터 상세</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600">실제 데이터</span>
<span className="text-sm font-medium text-green-600">
{stats.financial_data.real_data} ({((stats.financial_data.real_data / stats.financial_data.total_records) * 100).toFixed(1)}%)
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full"
style={{ width: `${(stats.financial_data.real_data / stats.financial_data.total_records) * 100}%` }}
></div>
</div>
</div>
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600">추정 데이터</span>
<span className="text-sm font-medium text-yellow-600">
{stats.financial_data.estimated_data} ({((stats.financial_data.estimated_data / stats.financial_data.total_records) * 100).toFixed(1)}%)
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-yellow-500 h-2 rounded-full"
style={{ width: `${(stats.financial_data.estimated_data / stats.financial_data.total_records) * 100}%` }}
></div>
</div>
</div>
<div className="mt-6">
<h4 className="text-sm font-medium text-gray-700 mb-2">데이터 기간</h4>
<p className="text-sm text-gray-600">
{new Date(stats.financial_data.date_range.earliest).toLocaleDateString('ko-KR')} ~ {new Date(stats.financial_data.date_range.latest).toLocaleDateString('ko-KR')}
</p>
</div>
</div>
</div>
{/* 데이터 소스별 분포 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">데이터 소스별 분포</h3>
<div className="space-y-3">
{Object.entries(stats.financial_data.by_source).map(([source, count]) => (
<div key={source} className="flex items-center justify-between">
<div className="flex items-center">
<div className="w-3 h-3 bg-blue-500 rounded-full mr-3"></div>
<span className="text-sm text-gray-700">{source}</span>
</div>
<div className="text-right">
<span className="text-sm font-medium text-gray-900">{formatNumber(count)}</span>
<span className="text-xs text-gray-500 ml-1">
({((count / stats.financial_data.total_records) * 100).toFixed(1)}%)
</span>
</div>
</div>
))}
</div>
</div>
</div>
{/* 주가 데이터 종목 목록 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">주가 데이터 보유 종목</h3>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-3">
{stats.price_data.tickers.map((ticker) => (
<div key={ticker} className="bg-gray-50 rounded-md px-3 py-2 text-center">
<span className="text-sm font-medium text-gray-900">{ticker}</span>
</div>
))}
</div>
<div className="mt-4 text-sm text-gray-600">
<p>주가 데이터 기간: {new Date(stats.price_data.date_range.earliest).toLocaleDateString('ko-KR')} ~ {new Date(stats.price_data.date_range.latest).toLocaleDateString('ko-KR')}</p>
</div>
</div>
</div>
);
};
export default DatabaseStats;

@ -0,0 +1,572 @@
import React, { useState, useEffect } from 'react';
import { adminApi } from '@/lib/api';
import {
AlertTriangle,
Filter,
RefreshCw,
CheckCircle,
XCircle,
Clock,
ChevronDown,
ChevronUp,
Eye,
Trash2,
Calendar
} from 'lucide-react';
interface ErrorLog {
id: number;
request_id: string;
endpoint: string;
method: string;
path: string;
query_params?: any;
request_body?: any;
error_type: string;
error_message: string;
error_detail?: any;
status_code: number;
stack_trace?: string;
user_agent?: string;
client_ip?: string;
response_time_ms?: number;
is_resolved: boolean;
resolved_at?: string;
resolution_notes?: string;
created_at: string;
}
interface ErrorStats {
total_errors: number;
resolved_errors: number;
unresolved_errors: number;
resolution_rate: number;
errors_by_type: Record<string, number>;
errors_by_status_code: Record<string, number>;
errors_by_endpoint: Record<string, number>;
average_response_time_ms: number;
}
interface ErrorLogListResponse {
items: ErrorLog[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
const ErrorLogViewer: React.FC = () => {
const [errorLogs, setErrorLogs] = useState<ErrorLog[]>([]);
const [stats, setStats] = useState<ErrorStats | null>(null);
const [loading, setLoading] = useState(true);
const [selectedLog, setSelectedLog] = useState<ErrorLog | null>(null);
const [showDetails, setShowDetails] = useState(false);
// Calculate default date range (last 30 days)
const getDefaultDateRange = () => {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 30);
return {
start_date: startDate.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0]
};
};
const [filters, setFilters] = useState({
page: 1,
page_size: 50,
error_type: '',
status_code: '',
endpoint: '',
is_resolved: '',
...getDefaultDateRange()
});
useEffect(() => {
fetchErrorLogs();
fetchStats();
}, [filters]);
const fetchErrorLogs = async () => {
setLoading(true);
try {
const params: Record<string, string> = {};
Object.entries(filters).forEach(([key, value]) => {
if (value !== '' && value !== null && value !== undefined) {
params[key] = value.toString();
}
});
const data: ErrorLogListResponse = await adminApi.getErrorLogs(params);
setErrorLogs(data.items);
} catch (error) {
console.error('Error fetching error logs:', error);
}
setLoading(false);
};
const fetchStats = async () => {
try {
const params: Record<string, string> = {};
if (filters.start_date) params.start_date = filters.start_date;
if (filters.end_date) params.end_date = filters.end_date;
const data: ErrorStats = await adminApi.getErrorStats(params);
setStats(data);
} catch (error) {
console.error('Error fetching stats:', error);
}
};
const markAsResolved = async (logId: number, resolved: boolean, notes?: string) => {
try {
const updatedLog = await adminApi.resolveError(logId, resolved, notes);
await fetchErrorLogs();
await fetchStats();
if (selectedLog && selectedLog.id === logId) {
setSelectedLog(updatedLog);
}
} catch (error) {
console.error('Error updating error log:', error);
}
};
const deleteOldLogs = async (daysOld: number, onlyResolved: boolean) => {
try {
const result = await adminApi.deleteErrorLogs({
days_old: daysOld.toString(),
only_resolved: onlyResolved.toString(),
});
await fetchErrorLogs();
await fetchStats();
alert(result.message);
} catch (error) {
console.error('Error deleting old logs:', error);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const getStatusCodeColor = (statusCode: number) => {
if (statusCode >= 500) return 'text-red-600 bg-red-100';
if (statusCode >= 400) return 'text-orange-600 bg-orange-100';
return 'text-gray-600 bg-gray-100';
};
const getErrorTypeColor = (errorType: string) => {
const colors: Record<string, string> = {
'VALIDATION_ERROR': 'text-blue-600 bg-blue-100',
'DATA_NOT_FOUND': 'text-yellow-600 bg-yellow-100',
'PARSING_ERROR': 'text-purple-600 bg-purple-100',
'SEC_API_ERROR': 'text-red-600 bg-red-100',
'DATABASE_ERROR': 'text-red-800 bg-red-200',
'INTERNAL_SERVER_ERROR': 'text-red-800 bg-red-200'
};
return colors[errorType] || 'text-gray-600 bg-gray-100';
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">에러 로그 관리</h1>
</div>
<div className="flex gap-2">
<button
onClick={() => fetchErrorLogs()}
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
<button
onClick={() => deleteOldLogs(30, true)}
className="flex items-center gap-2 px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600"
>
<Trash2 className="w-4 h-4" />
Delete Old
</button>
</div>
</div>
{/* Statistics */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-red-600">Total Errors</p>
<p className="text-2xl font-bold text-red-700">{stats.total_errors}</p>
</div>
<AlertTriangle className="w-8 h-8 text-red-500" />
</div>
</div>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-green-600">Resolved</p>
<p className="text-2xl font-bold text-green-700">{stats.resolved_errors}</p>
</div>
<CheckCircle className="w-8 h-8 text-green-500" />
</div>
</div>
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-orange-600">Unresolved</p>
<p className="text-2xl font-bold text-orange-700">{stats.unresolved_errors}</p>
</div>
<XCircle className="w-8 h-8 text-orange-500" />
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-blue-600">Resolution Rate</p>
<p className="text-2xl font-bold text-blue-700">{stats.resolution_rate.toFixed(1)}%</p>
</div>
<Clock className="w-8 h-8 text-blue-500" />
</div>
</div>
</div>
)}
{/* Filters */}
<div className="bg-white p-4 rounded-lg border border-gray-200">
<div className="flex items-center gap-2 mb-4">
<Filter className="w-5 h-5 text-gray-500" />
<h3 className="text-lg font-semibold">Filters</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Error Type
</label>
<select
value={filters.error_type}
onChange={(e) => setFilters(prev => ({ ...prev, error_type: e.target.value, page: 1 }))}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
>
<option value="">All Types</option>
<option value="VALIDATION_ERROR">Validation Error</option>
<option value="DATA_NOT_FOUND">Data Not Found</option>
<option value="PARSING_ERROR">Parsing Error</option>
<option value="SEC_API_ERROR">SEC API Error</option>
<option value="DATABASE_ERROR">Database Error</option>
<option value="INTERNAL_SERVER_ERROR">Internal Server Error</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status Code
</label>
<select
value={filters.status_code}
onChange={(e) => setFilters(prev => ({ ...prev, status_code: e.target.value, page: 1 }))}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
>
<option value="">All Codes</option>
<option value="400">400</option>
<option value="404">404</option>
<option value="422">422</option>
<option value="500">500</option>
<option value="503">503</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Endpoint
</label>
<input
type="text"
value={filters.endpoint}
onChange={(e) => setFilters(prev => ({ ...prev, endpoint: e.target.value, page: 1 }))}
placeholder="e.g. /financial/data"
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
value={filters.is_resolved}
onChange={(e) => setFilters(prev => ({ ...prev, is_resolved: e.target.value, page: 1 }))}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
>
<option value="">All</option>
<option value="false">Unresolved</option>
<option value="true">Resolved</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Start Date
</label>
<input
type="date"
value={filters.start_date}
onChange={(e) => setFilters(prev => ({ ...prev, start_date: e.target.value, page: 1 }))}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
End Date
</label>
<input
type="date"
value={filters.end_date}
onChange={(e) => setFilters(prev => ({ ...prev, end_date: e.target.value, page: 1 }))}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
{/* Error Logs Table */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Time
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Endpoint
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Error Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Message
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Resolution
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{loading ? (
<tr>
<td colSpan={7} className="px-6 py-4 text-center text-gray-500">
Loading error logs...
</td>
</tr>
) : errorLogs.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-4 text-center text-gray-500">
No error logs found
</td>
</tr>
) : (
errorLogs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatDate(log.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<div>
<span className="font-medium">{log.method}</span>
<br />
<span className="text-gray-500 text-xs">{log.endpoint}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getErrorTypeColor(log.error_type)}`}>
{log.error_type}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusCodeColor(log.status_code)}`}>
{log.status_code}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-900 max-w-xs truncate">
{log.error_message}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{log.is_resolved ? (
<span className="flex items-center gap-1 text-green-600">
<CheckCircle className="w-4 h-4" />
Resolved
</span>
) : (
<span className="flex items-center gap-1 text-red-600">
<XCircle className="w-4 h-4" />
Open
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button
onClick={() => {
setSelectedLog(log);
setShowDetails(true);
}}
className="text-blue-600 hover:text-blue-900"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => markAsResolved(log.id, !log.is_resolved)}
className={log.is_resolved ? "text-orange-600 hover:text-orange-900" : "text-green-600 hover:text-green-900"}
>
{log.is_resolved ? <XCircle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
<div className="flex justify-between items-center">
<div className="text-sm text-gray-700">
Showing {errorLogs.length} errors
</div>
<div className="flex gap-2">
<button
onClick={() => setFilters(prev => ({ ...prev, page: Math.max(1, prev.page - 1) }))}
disabled={filters.page <= 1}
className="px-3 py-2 bg-gray-300 text-gray-700 rounded disabled:opacity-50"
>
Previous
</button>
<span className="px-3 py-2 bg-blue-500 text-white rounded">
{filters.page}
</span>
<button
onClick={() => setFilters(prev => ({ ...prev, page: prev.page + 1 }))}
disabled={errorLogs.length < filters.page_size}
className="px-3 py-2 bg-gray-300 text-gray-700 rounded disabled:opacity-50"
>
Next
</button>
</div>
</div>
{/* Error Details Modal */}
{showDetails && selectedLog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-auto">
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<h2 className="text-xl font-bold">Error Details</h2>
<button
onClick={() => setShowDetails(false)}
className="text-gray-500 hover:text-gray-700"
>
✕
</button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Request ID</label>
<p className="text-sm text-gray-900 font-mono">{selectedLog.request_id}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Timestamp</label>
<p className="text-sm text-gray-900">{formatDate(selectedLog.created_at)}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Method & Endpoint</label>
<p className="text-sm text-gray-900 font-mono">
{selectedLog.method} {selectedLog.endpoint}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Status Code</label>
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusCodeColor(selectedLog.status_code)}`}>
{selectedLog.status_code}
</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Error Message</label>
<p className="text-sm text-gray-900 bg-gray-50 p-3 rounded">{selectedLog.error_message}</p>
</div>
{selectedLog.error_detail && (
<div>
<label className="block text-sm font-medium text-gray-700">Error Details</label>
<pre className="text-sm text-gray-900 bg-gray-50 p-3 rounded overflow-auto">
{JSON.stringify(selectedLog.error_detail, null, 2)}
</pre>
</div>
)}
{selectedLog.request_body && (
<div>
<label className="block text-sm font-medium text-gray-700">Request Body</label>
<pre className="text-sm text-gray-900 bg-gray-50 p-3 rounded overflow-auto">
{JSON.stringify(selectedLog.request_body, null, 2)}
</pre>
</div>
)}
{selectedLog.stack_trace && (
<div>
<label className="block text-sm font-medium text-gray-700">Stack Trace</label>
<pre className="text-sm text-gray-900 bg-red-50 p-3 rounded overflow-auto text-red-800">
{selectedLog.stack_trace}
</pre>
</div>
)}
<div className="flex gap-4 pt-4 border-t">
<button
onClick={() => markAsResolved(selectedLog.id, !selectedLog.is_resolved)}
className={`px-4 py-2 rounded text-white ${
selectedLog.is_resolved
? 'bg-orange-500 hover:bg-orange-600'
: 'bg-green-500 hover:bg-green-600'
}`}
>
{selectedLog.is_resolved ? 'Mark as Unresolved' : 'Mark as Resolved'}
</button>
<button
onClick={() => setShowDetails(false)}
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600"
>
Close
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default ErrorLogViewer;

@ -0,0 +1,16 @@
import dynamic from 'next/dynamic';
// Dynamic import with SSR disabled to prevent hydration errors
const ClientLayout = dynamic(() => import('./ClientLayout'), {
ssr: false,
loading: () => (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading Stock Oracle...</p>
</div>
</div>
),
});
export default ClientLayout;

@ -0,0 +1,14 @@
import React from 'react';
import ClientLayout from './ClientLayout';
import UnifiedLogViewer from './UnifiedLogViewer';
// This component is only loaded client-side via dynamic import
const LogsPageContent: React.FC = () => {
return (
<ClientLayout>
<UnifiedLogViewer />
</ClientLayout>
);
};
export default LogsPageContent;

@ -0,0 +1,403 @@
import React, { useState, useEffect } from 'react';
import { Newspaper, MessageCircle, ExternalLink, Clock, User, TrendingUp, Calendar, RefreshCw } from 'lucide-react';
import { stockApi, NewsSocialRequest, NewsSocialResponse, NewsArticle, SocialPost, formatDate } from '@/lib/api';
interface NewsSocialDisplayProps {
ticker: string;
}
const NewsSocialDisplay: React.FC<NewsSocialDisplayProps> = ({ ticker }) => {
const [data, setData] = useState<NewsSocialResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'all' | 'news' | 'social'>('all');
const [settings, setSettings] = useState<NewsSocialRequest>({
ticker: ticker,
days_back: 7,
max_articles: 15,
max_social_posts: 10,
include_social: true,
});
// Update ticker when prop changes and clear previous data
useEffect(() => {
// Clear previous data immediately when ticker changes
if (settings.ticker !== ticker) {
setData(null);
setError(null);
setLoading(false);
setSettings(prev => ({ ...prev, ticker: ticker }));
}
}, [ticker, settings.ticker]);
// Auto-fetch when settings change (but not on initial ticker update)
useEffect(() => {
if (ticker && ticker.length > 0 && settings.ticker === ticker) {
fetchNewsSocialData();
}
}, [settings.ticker, settings.days_back, settings.max_articles, settings.max_social_posts, settings.include_social]);
const fetchNewsSocialData = async () => {
if (!ticker) return;
setLoading(true);
setError(null);
// Clear previous data when starting new fetch
setData(null);
try {
const result = await stockApi.getNewsSocialData(settings);
setData(result);
} catch (err) {
console.error('News/Social data fetch error:', err);
setError(err instanceof Error ? err.message : '뉴스 및 소셜 미디어 데이터를 가져오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
const formatTimeAgo = (publishedAt?: string) => {
if (!publishedAt) return '';
const now = new Date();
const published = new Date(publishedAt);
const diffInHours = Math.floor((now.getTime() - published.getTime()) / (1000 * 60 * 60));
if (diffInHours < 1) return '방금 전';
if (diffInHours < 24) return `${diffInHours}시간 전`;
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) return `${diffInDays}일 전`;
return formatDate(publishedAt);
};
const NewsCard: React.FC<{ article: NewsArticle }> = ({ article }) => (
<div className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center text-sm text-gray-500 mb-2">
<Newspaper className="w-4 h-4 mr-1" />
<span className="font-medium text-blue-600">{article.source}</span>
{article.published_at && (
<>
<Clock className="w-3 h-3 mx-2" />
<span>{formatTimeAgo(article.published_at)}</span>
</>
)}
</div>
</div>
<h3 className="font-semibold text-gray-900 mb-2 leading-tight">
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-blue-600 transition-colors"
>
{article.title}
</a>
</h3>
{article.summary && (
<p className="text-gray-600 text-sm mb-3 overflow-hidden" style={{
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical'
}}>
{article.summary}
</p>
)}
<div className="flex items-center justify-between">
<div className="flex items-center text-xs text-gray-500">
{article.author && (
<>
<User className="w-3 h-3 mr-1" />
<span>{article.author}</span>
</>
)}
</div>
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-blue-600 hover:text-blue-800 text-sm font-medium"
>
읽기
<ExternalLink className="w-3 h-3 ml-1" />
</a>
</div>
</div>
);
const SocialCard: React.FC<{ post: SocialPost }> = ({ post }) => (
<div className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center text-sm text-gray-500">
<MessageCircle className="w-4 h-4 mr-1" />
<span className="font-medium text-orange-600">{post.platform}</span>
{post.subreddit && (
<span className="ml-1 text-gray-400">r/{post.subreddit}</span>
)}
{post.published_at && (
<>
<Clock className="w-3 h-3 mx-2" />
<span>{formatTimeAgo(post.published_at)}</span>
</>
)}
</div>
{typeof post.score === 'number' && (
<div className="flex items-center text-sm">
<TrendingUp className="w-3 h-3 mr-1 text-green-500" />
<span className="font-medium text-green-600">{post.score}</span>
</div>
)}
</div>
<h3 className="font-semibold text-gray-900 mb-2">
<a
href={post.url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-blue-600 transition-colors"
>
{post.title}
</a>
</h3>
{post.content && post.content.length > 0 && (
<p className="text-gray-600 text-sm mb-3 overflow-hidden" style={{
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical'
}}>
{post.content}
</p>
)}
<div className="flex items-center justify-between">
<div className="flex items-center text-xs text-gray-500">
<User className="w-3 h-3 mr-1" />
<span>{post.author}</span>
{typeof post.comments_count === 'number' && post.comments_count > 0 && (
<>
<MessageCircle className="w-3 h-3 ml-3 mr-1" />
<span>{post.comments_count} 댓글</span>
</>
)}
</div>
<a
href={post.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-blue-600 hover:text-blue-800 text-sm font-medium"
>
보기
<ExternalLink className="w-3 h-3 ml-1" />
</a>
</div>
</div>
);
if (!ticker) {
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-6 text-center">
<Newspaper className="w-12 h-12 text-gray-400 mx-auto mb-2" />
<p className="text-gray-600">종목을 선택하면 관련 뉴스와 소셜 미디어 데이터를 확인할 수 있습니다.</p>
</div>
);
}
return (
<div className="space-y-4">
{/* 헤더 및 설정 */}
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">
뉴스 & 소셜 미디어 ({ticker})
</h2>
<button
onClick={() => {
setData(null); // Clear data immediately on refresh
fetchNewsSocialData();
}}
disabled={loading}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
새로고침
</button>
</div>
{/* 설정 옵션 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">기간</label>
<select
value={settings.days_back}
onChange={(e) => setSettings(prev => ({ ...prev, days_back: parseInt(e.target.value) }))}
className="w-full border border-gray-300 rounded-md px-3 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value={1}>1일</option>
<option value={3}>3일</option>
<option value={7}>1주</option>
<option value={14}>2주</option>
<option value={30}>1개월</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">뉴스 수</label>
<select
value={settings.max_articles}
onChange={(e) => setSettings(prev => ({ ...prev, max_articles: parseInt(e.target.value) }))}
className="w-full border border-gray-300 rounded-md px-3 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value={5}>5개</option>
<option value={10}>10개</option>
<option value={15}>15개</option>
<option value={20}>20개</option>
<option value={30}>30개</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">소셜 수</label>
<select
value={settings.max_social_posts}
onChange={(e) => setSettings(prev => ({ ...prev, max_social_posts: parseInt(e.target.value) }))}
className="w-full border border-gray-300 rounded-md px-3 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value={0}>0개</option>
<option value={5}>5개</option>
<option value={10}>10개</option>
<option value={15}>15개</option>
<option value={20}>20개</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">소셜 미디어</label>
<select
value={settings.include_social ? 'true' : 'false'}
onChange={(e) => setSettings(prev => ({ ...prev, include_social: e.target.value === 'true' }))}
className="w-full border border-gray-300 rounded-md px-3 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="true">포함</option>
<option value="false">제외</option>
</select>
</div>
</div>
{/* 탭 */}
<div className="flex space-x-1 bg-gray-100 p-1 rounded-lg">
<button
onClick={() => setActiveTab('all')}
className={`flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'all'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
전체
</button>
<button
onClick={() => setActiveTab('news')}
className={`flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'news'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
뉴스만
</button>
<button
onClick={() => setActiveTab('social')}
className={`flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'social'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
소셜만
</button>
</div>
</div>
{/* 로딩 상태 */}
{loading && (
<div className="bg-white border border-gray-200 rounded-lg p-8 text-center">
<RefreshCw className="w-8 h-8 text-gray-400 mx-auto mb-2 animate-spin" />
<p className="text-gray-600">데이터를 불러오는 중...</p>
</div>
)}
{/* 오류 상태 */}
{error && !loading && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-800">{error}</p>
</div>
)}
{/* 데이터 표시 - 로딩 중이 아니고 에러도 없을 때만 */}
{!loading && !error && data && (
<>
{/* 요약 정보 - 데이터가 있을 때만 표시 */}
{data.summary.total_items > 0 && (
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{data.news.total_articles}</div>
<div className="text-sm text-gray-600">뉴스 기사</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-orange-600">{data.social_media.total_posts}</div>
<div className="text-sm text-gray-600">소셜 포스트</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{data.summary.total_items}</div>
<div className="text-sm text-gray-600">전체 항목</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">{data.summary.time_range_days}</div>
<div className="text-sm text-gray-600">조회 일수</div>
</div>
</div>
</div>
)}
{/* 콘텐츠 표시 */}
{(activeTab === 'all' || activeTab === 'news') && data.news.articles.length > 0 && (
<div className="space-y-4">
{activeTab === 'all' && <h3 className="font-semibold text-gray-900">뉴스 기사</h3>}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data.news.articles.map((article, index) => (
<NewsCard key={index} article={article} />
))}
</div>
</div>
)}
{(activeTab === 'all' || activeTab === 'social') && data.social_media.posts.length > 0 && (
<div className="space-y-4">
{activeTab === 'all' && <h3 className="font-semibold text-gray-900">소셜 미디어</h3>}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data.social_media.posts.map((post, index) => (
<SocialCard key={index} post={post} />
))}
</div>
</div>
)}
{/* 데이터 없음 메시지 */}
{data.summary.total_items === 0 && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<Calendar className="w-12 h-12 text-gray-400 mx-auto mb-2" />
<p className="text-gray-600">{ticker}에 대한 뉴스가 없습니다.</p>
<p className="text-gray-500 text-sm mt-1">다른 기간을 선택하거나 다른 종목을 검색해 보세요.</p>
</div>
)}
</>
)}
</div>
);
};
export default NewsSocialDisplay;

@ -0,0 +1,433 @@
import React, { useState, useEffect } from 'react';
import {
Clock,
Globe,
Activity,
Filter,
ChevronDown,
ChevronUp,
Calendar,
BarChart3,
RefreshCw
} from 'lucide-react';
interface RequestLog {
id: number;
request_id: string;
endpoint: string;
method: string;
path: string;
query_params?: any;
request_body?: any;
status_code: number;
response_size?: number;
user_agent?: string;
client_ip?: string;
response_time_ms?: number;
created_at?: string;
}
interface RequestLogStats {
total_requests: number;
success_requests: number;
client_error_requests: number;
server_error_requests: number;
success_rate: number;
requests_by_method: { [key: string]: number };
requests_by_status_code: { [key: string]: number };
requests_by_endpoint: { [key: string]: number };
average_response_time_ms: number;
start_date: string;
end_date: string;
}
const RequestLogViewer: React.FC = () => {
const [logs, setLogs] = useState<RequestLog[]>([]);
const [stats, setStats] = useState<RequestLogStats | null>(null);
const [loading, setLoading] = useState(true);
const [showFilters, setShowFilters] = useState(false);
const [showStats, setShowStats] = useState(false);
const [expandedLog, setExpandedLog] = useState<number | null>(null);
// Filter state
const [filters, setFilters] = useState({
start_date: '', // Remove default date filter for debugging
end_date: '', // Remove default date filter for debugging
method: '',
status_code: '',
endpoint: '',
page: 1,
page_size: 50
});
function getDefaultDateRange() {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 7); // Last 7 days for requests
return {
start_date: startDate.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0]
};
}
const fetchLogs = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value) {
// Add timezone info for date parameters
if (key === 'start_date') {
params.append(key, value.toString() + 'T00:00:00Z');
} else if (key === 'end_date') {
params.append(key, value.toString() + 'T23:59:59Z');
} else {
params.append(key, value.toString());
}
}
});
const response = await fetch(`/api/v1/admin/requests/logs?${params}`);
if (response.ok) {
const data = await response.json();
setLogs(data.items || []);
}
} catch (error) {
console.error('Error fetching request logs:', error);
}
setLoading(false);
};
const fetchStats = async () => {
try {
const params = new URLSearchParams();
if (filters.start_date) params.append('start_date', filters.start_date + 'T00:00:00Z');
if (filters.end_date) params.append('end_date', filters.end_date + 'T23:59:59Z');
const response = await fetch(`/api/v1/admin/requests/stats?${params}`);
if (response.ok) {
const data = await response.json();
setStats(data);
}
} catch (error) {
console.error('Error fetching request stats:', error);
}
};
useEffect(() => {
fetchLogs();
fetchStats();
}, [filters]);
const getStatusCodeColor = (statusCode: number): string => {
if (statusCode >= 200 && statusCode < 300) return 'text-green-600 bg-green-50';
if (statusCode >= 300 && statusCode < 400) return 'text-blue-600 bg-blue-50';
if (statusCode >= 400 && statusCode < 500) return 'text-yellow-600 bg-yellow-50';
if (statusCode >= 500) return 'text-red-600 bg-red-50';
return 'text-gray-600 bg-gray-50';
};
const getMethodColor = (method: string): string => {
const colors: Record<string, string> = {
'GET': 'text-green-600 bg-green-50',
'POST': 'text-blue-600 bg-blue-50',
'PUT': 'text-yellow-600 bg-yellow-50',
'DELETE': 'text-red-600 bg-red-50',
'PATCH': 'text-purple-600 bg-purple-50'
};
return colors[method] || 'text-gray-600 bg-gray-50';
};
const formatResponseTime = (ms?: number): string => {
if (!ms) return 'N/A';
if (ms < 1000) return `${ms.toFixed(0)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
};
const formatSize = (bytes?: number): string => {
if (!bytes) return 'N/A';
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
};
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-2">
<Activity className="w-8 h-8 text-blue-600" />
요청 로그
</h1>
<p className="text-gray-600 mt-2">
API 요청 내역 및 통계를 확인할 수 있습니다
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowStats(!showStats)}
className={`px-4 py-2 rounded-lg flex items-center gap-2 ${
showStats
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50'
} border transition-colors`}
>
<BarChart3 className="w-4 h-4" />
통계
</button>
<button
onClick={() => setShowFilters(!showFilters)}
className={`px-4 py-2 rounded-lg flex items-center gap-2 ${
showFilters
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50'
} border transition-colors`}
>
<Filter className="w-4 h-4" />
필터
{showFilters ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
<button
onClick={() => { fetchLogs(); fetchStats(); }}
className="px-4 py-2 bg-white text-gray-600 hover:bg-gray-50 border rounded-lg flex items-center gap-2 transition-colors"
>
<RefreshCw className="w-4 h-4" />
새로고침
</button>
</div>
</div>
</div>
{/* Statistics */}
{showStats && stats && (
<div className="mb-6 grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">총 요청</p>
<p className="text-2xl font-bold">{stats.total_requests.toLocaleString()}</p>
</div>
<Globe className="w-8 h-8 text-blue-600" />
</div>
</div>
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">성공률</p>
<p className="text-2xl font-bold text-green-600">{stats.success_rate.toFixed(1)}%</p>
</div>
<Activity className="w-8 h-8 text-green-600" />
</div>
</div>
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">평균 응답시간</p>
<p className="text-2xl font-bold">{formatResponseTime(stats.average_response_time_ms)}</p>
</div>
<Clock className="w-8 h-8 text-orange-600" />
</div>
</div>
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">서버 에러</p>
<p className="text-2xl font-bold text-red-600">{stats.server_error_requests.toLocaleString()}</p>
</div>
<Activity className="w-8 h-8 text-red-600" />
</div>
</div>
</div>
)}
{/* Filters */}
{showFilters && (
<div className="mb-6 bg-white p-4 rounded-lg border">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">시작 날짜</label>
<input
type="date"
value={filters.start_date}
onChange={(e) => setFilters({ ...filters, start_date: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">종료 날짜</label>
<input
type="date"
value={filters.end_date}
onChange={(e) => setFilters({ ...filters, end_date: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">HTTP 메서드</label>
<select
value={filters.method}
onChange={(e) => setFilters({ ...filters, method: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">전체</option>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
<option value="PATCH">PATCH</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">상태 코드</label>
<input
type="number"
placeholder="예: 200, 404, 500"
value={filters.status_code}
onChange={(e) => setFilters({ ...filters, status_code: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">엔드포인트</label>
<input
type="text"
placeholder="예: /api/v1/financial/*"
value={filters.endpoint}
onChange={(e) => setFilters({ ...filters, endpoint: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
)}
{/* Logs Table */}
<div className="bg-white rounded-lg border overflow-hidden">
{loading ? (
<div className="p-8 text-center">
<RefreshCw className="w-8 h-8 text-blue-600 animate-spin mx-auto mb-4" />
<p className="text-gray-600">요청 로그를 불러오는 중...</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
시간
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
메서드
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
엔드포인트
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
상태
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
응답시간
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
크기
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
IP
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
상세
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{logs.map((log) => (
<React.Fragment key={log.id}>
<tr className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{log.created_at ? new Date(log.created_at).toLocaleString('ko-KR') : 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getMethodColor(log.method)}`}>
{log.method}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-900 max-w-xs truncate" title={log.path}>
{log.endpoint}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusCodeColor(log.status_code)}`}>
{log.status_code}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatResponseTime(log.response_time_ms)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatSize(log.response_size)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{log.client_ip || 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<button
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
className="text-blue-600 hover:text-blue-800"
>
{expandedLog === log.id ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
</td>
</tr>
{expandedLog === log.id && (
<tr>
<td colSpan={8} className="px-6 py-4 bg-gray-50">
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-900 mb-2">요청 정보</h4>
<div className="bg-white p-3 rounded border text-sm">
<p><span className="font-medium">Request ID:</span> {log.request_id}</p>
<p><span className="font-medium">Full Path:</span> {log.path}</p>
<p><span className="font-medium">User Agent:</span> {log.user_agent || 'N/A'}</p>
{log.query_params && Object.keys(log.query_params).length > 0 && (
<div>
<span className="font-medium">Query Parameters:</span>
<pre className="mt-1 bg-gray-50 p-2 rounded text-xs overflow-x-auto">
{JSON.stringify(log.query_params, null, 2)}
</pre>
</div>
)}
{log.request_body && (
<div>
<span className="font-medium">Request Body:</span>
<pre className="mt-1 bg-gray-50 p-2 rounded text-xs overflow-x-auto">
{JSON.stringify(log.request_body, null, 2)}
</pre>
</div>
)}
</div>
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
{logs.length === 0 && (
<div className="p-8 text-center">
<Activity className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500">조건에 맞는 요청 로그가 없습니다.</p>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
};
export default RequestLogViewer;

@ -0,0 +1,262 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Search, Calendar, TrendingUp, DollarSign, BarChart3, AlertCircle } from 'lucide-react';
import { stockApi, FinancialDataRequest, FinancialDataResponse, formatCurrency, formatPercentage, formatDate } from '@/lib/api';
const StockQueryFixed: React.FC = () => {
// Use individual state for each field to avoid controlled component issues
const [ticker, setTicker] = useState<string>('');
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [periodType, setPeriodType] = useState<'quarterly' | 'annual' | 'all'>('quarterly');
const [includeMetrics, setIncludeMetrics] = useState<boolean>(true);
const [forceRefresh, setForceRefresh] = useState<boolean>(false);
const [data, setData] = useState<FinancialDataResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Initialize default values
useEffect(() => {
const today = new Date();
const oneYearAgo = new Date();
oneYearAgo.setFullYear(today.getFullYear() - 1);
setTicker('AAPL');
setStartDate(oneYearAgo.toISOString().split('T')[0]);
setEndDate(today.toISOString().split('T')[0]);
}, []);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
const query: FinancialDataRequest = {
ticker: ticker.trim().toUpperCase(),
start_date: startDate,
end_date: endDate,
period_type: periodType,
include_metrics: includeMetrics,
force_refresh: forceRefresh,
};
console.log('Submitting query:', query);
try {
const result = await stockApi.getFinancialData(query);
setData(result);
console.log('API Response:', result);
} catch (err) {
console.error('API Error:', err);
setError(err instanceof Error ? err.message : '데이터를 가져오는데 실패했습니다.');
} finally {
setLoading(false);
}
}, [ticker, startDate, endDate, periodType, includeMetrics, forceRefresh]);
return (
<div className="space-y-6">
{/* 검색 폼 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<Search className="h-5 w-5 mr-2 text-blue-600" />
주식 데이터 조회
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* 종목 코드 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
종목 코드
</label>
<input
type="text"
value={ticker}
onChange={(e) => setTicker(e.target.value.toUpperCase())}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-gray-900 bg-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="예: AAPL"
required
/>
</div>
{/* 시작 날짜 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
시작 날짜
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
{/* 종료 날짜 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
종료 날짜
</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
{/* 기간 타입 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
기간 타입
</label>
<select
value={periodType}
onChange={(e) => setPeriodType(e.target.value as 'quarterly' | 'annual' | 'all')}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="quarterly">분기별</option>
<option value="annual">연간</option>
<option value="all">전체</option>
</select>
</div>
{/* 옵션들 */}
<div className="flex items-center space-x-4 col-span-full">
<label className="flex items-center">
<input
type="checkbox"
checked={includeMetrics}
onChange={(e) => setIncludeMetrics(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">지표 포함</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={forceRefresh}
onChange={(e) => setForceRefresh(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">강제 새로고침</span>
</label>
</div>
</div>
{/* 검색 버튼 */}
<div className="flex justify-end">
<button
type="submit"
disabled={loading}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<>
<div className="animate-spin -ml-1 mr-3 h-4 w-4 border-2 border-white border-t-transparent rounded-full"></div>
검색 중...
</>
) : (
<>
<Search className="h-4 w-4 mr-2" />
검색
</>
)}
</button>
</div>
</form>
</div>
{/* 에러 메시지 */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<AlertCircle className="h-5 w-5 text-red-400 mr-2" />
<p className="text-red-800">{error}</p>
</div>
</div>
)}
{/* 결과 표시 */}
{data && (
<div className="space-y-6">
{/* 회사 정보 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<TrendingUp className="h-5 w-5 mr-2 text-blue-600" />
회사 정보
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-600">회사명</p>
<p className="font-medium">{data.company.name}</p>
</div>
<div>
<p className="text-sm text-gray-600">종목 코드</p>
<p className="font-medium">{data.company.ticker}</p>
</div>
<div>
<p className="text-sm text-gray-600">섹터</p>
<p className="font-medium">{data.company.sector || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-gray-600">산업</p>
<p className="font-medium">{data.company.industry || 'N/A'}</p>
</div>
</div>
</div>
{/* 재무 데이터 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<BarChart3 className="h-5 w-5 mr-2 text-green-600" />
재무 데이터 ({data.financial_data.length}개 기간)
</h3>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">기간</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">매출</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">순이익</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">총자산</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">P/E 비율</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{data.financial_data.map((item, index) => (
<tr key={index} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatDate(item.period_date)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatCurrency(item.revenue)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatCurrency(item.net_income)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatCurrency(item.total_assets)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.pe_ratio?.toFixed(2) || 'N/A'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
);
};
export default StockQueryFixed;

@ -0,0 +1,689 @@
import React, { useState, useEffect } from 'react';
import { adminApi } from '@/lib/api';
import {
Clock,
Globe,
Activity,
Filter,
ChevronDown,
ChevronUp,
Calendar,
BarChart3,
RefreshCw,
AlertTriangle,
CheckCircle,
XCircle,
AlertCircle,
Trash2
} from 'lucide-react';
interface LogEntry {
id: number;
request_id: string;
endpoint: string;
method: string;
path: string;
query_params?: any;
request_body?: any;
headers?: any;
status_code: number;
response_size?: number;
user_agent?: string;
client_ip?: string;
response_time_ms?: number;
created_at?: string;
// Error specific fields
error_type?: string;
error_message?: string;
error_detail?: any;
stack_trace?: string;
is_error?: boolean;
}
interface LogStats {
total_requests: number;
success_requests: number;
client_error_requests: number;
server_error_requests: number;
success_rate: number;
requests_by_method: { [key: string]: number };
requests_by_status_code: { [key: string]: number };
requests_by_endpoint: { [key: string]: number };
average_response_time_ms: number;
start_date: string;
end_date: string;
}
const UnifiedLogViewer: React.FC = () => {
const [isClient, setIsClient] = useState(false);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [stats, setStats] = useState<LogStats | null>(null);
const [loading, setLoading] = useState(true);
const [showFilters, setShowFilters] = useState(false);
const [showStats, setShowStats] = useState(true);
const [expandedLog, setExpandedLog] = useState<number | null>(null);
const [logType, setLogType] = useState<'all' | 'errors' | 'success' | 'data'>('data');
// Filter state
const [filters, setFilters] = useState({
start_date: '',
end_date: '',
method: '',
status_code: '',
endpoint: '',
page: 1,
page_size: 50
});
const fetchLogs = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value) {
if (key === 'start_date') {
params.append(key, value.toString() + 'T00:00:00Z');
} else if (key === 'end_date') {
params.append(key, value.toString() + 'T23:59:59Z');
} else {
params.append(key, value.toString());
}
}
});
// Filter by log type
if (logType === 'errors') {
params.append('status_code', '500');
} else if (logType === 'success') {
params.append('status_code', '200');
}
let allLogs: LogEntry[] = [];
if (logType === 'data') {
// Fetch financial and price data requests separately and combine
try {
const financialParams = new URLSearchParams(params);
financialParams.append('endpoint', '/api/v1/financial/*');
const priceParams = new URLSearchParams(params);
priceParams.append('endpoint', '/api/v1/price/*');
const [financialData, priceData] = await Promise.all([
adminApi.getRequestLogs(Object.fromEntries(financialParams)),
adminApi.getRequestLogs(Object.fromEntries(priceParams))
]);
// Combine and sort by created_at
allLogs = [...(financialData.items || []), ...(priceData.items || [])].sort((a: LogEntry, b: LogEntry) =>
new Date(b.created_at || '').getTime() - new Date(a.created_at || '').getTime()
);
} catch (error) {
console.error('Error fetching data logs:', error);
}
} else {
try {
const data = await adminApi.getRequestLogs(Object.fromEntries(params));
allLogs = data.items || [];
} catch (error) {
console.error('Error fetching logs:', error);
}
}
// Fetch error details for 500 errors
const enrichedLogs = await Promise.all(
allLogs.map(async (log: LogEntry) => {
if (log.status_code >= 500) {
try {
const errorData = await adminApi.getErrorByRequestId(log.request_id);
return {
...log,
is_error: true,
error_type: errorData.error_type,
error_message: errorData.error_message,
error_detail: errorData.error_detail,
stack_trace: errorData.stack_trace
};
} catch (error) {
console.error('Error fetching error details:', error);
}
}
return { ...log, is_error: log.status_code >= 400 };
})
);
setLogs(enrichedLogs);
} catch (error) {
console.error('Error fetching logs:', error);
}
setLoading(false);
};
const fetchStats = async () => {
try {
const params: Record<string, string> = {};
if (filters.start_date) params.start_date = filters.start_date + 'T00:00:00Z';
if (filters.end_date) params.end_date = filters.end_date + 'T23:59:59Z';
const data = await adminApi.getRequestStats(params);
setStats(data);
} catch (error) {
console.error('Error fetching stats:', error);
}
};
const clearAllLogs = async () => {
if (!window.confirm('정말로 모든 로그를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) {
return;
}
try {
setLoading(true);
// Clear both request logs and error logs
const [requestResult, errorResult] = await Promise.all([
adminApi.deleteRequestLogs({ confirm: 'true' }),
adminApi.deleteErrorLogs({ confirm: 'true' })
]);
alert(`성공적으로 삭제되었습니다:\n- 요청 로그: ${requestResult.deleted_count}개\n- 에러 로그: ${errorResult.deleted_count}개`);
// Refresh data
await fetchLogs();
await fetchStats();
} catch (error) {
console.error('Error clearing logs:', error);
alert('로그 삭제 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
useEffect(() => {
setIsClient(true);
}, []);
useEffect(() => {
if (isClient) {
fetchLogs();
fetchStats();
}
}, [filters, logType, isClient]);
const getStatusIcon = (statusCode: number) => {
if (statusCode >= 200 && statusCode < 300) return <CheckCircle className="w-4 h-4 text-green-600" />;
if (statusCode >= 400 && statusCode < 500) return <AlertCircle className="w-4 h-4 text-yellow-600" />;
if (statusCode >= 500) return <XCircle className="w-4 h-4 text-red-600" />;
return <Activity className="w-4 h-4 text-gray-600" />;
};
const getStatusCodeColor = (statusCode: number): string => {
if (statusCode >= 200 && statusCode < 300) return 'text-green-600 bg-green-50';
if (statusCode >= 300 && statusCode < 400) return 'text-blue-600 bg-blue-50';
if (statusCode >= 400 && statusCode < 500) return 'text-yellow-600 bg-yellow-50';
if (statusCode >= 500) return 'text-red-600 bg-red-50';
return 'text-gray-600 bg-gray-50';
};
const getMethodColor = (method: string): string => {
const colors: Record<string, string> = {
'GET': 'text-green-600 bg-green-50',
'POST': 'text-blue-600 bg-blue-50',
'PUT': 'text-yellow-600 bg-yellow-50',
'DELETE': 'text-red-600 bg-red-50',
'PATCH': 'text-purple-600 bg-purple-50'
};
return colors[method] || 'text-gray-600 bg-gray-50';
};
const formatResponseTime = (ms?: number): string => {
if (!ms) return 'N/A';
if (ms < 1000) return `${ms.toFixed(0)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
};
const formatSize = (bytes?: number): string => {
if (!bytes) return 'N/A';
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
};
const formatDateTime = (dateString?: string): string => {
if (!dateString || !isClient) return 'N/A';
try {
return new Date(dateString).toLocaleString('ko-KR');
} catch (error) {
return dateString;
}
};
if (!isClient) {
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-7xl mx-auto">
<div className="p-8 text-center">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-600">로그 시스템 초기화 중...</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-2">
<Activity className="w-8 h-8 text-blue-600" />
시스템 로그
</h1>
<p className="text-gray-600 mt-2">
모든 API 요청 및 에러 로그를 확인할 수 있습니다
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowStats(!showStats)}
className={`px-4 py-2 rounded-lg flex items-center gap-2 ${
showStats
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50'
} border transition-colors`}
>
<BarChart3 className="w-4 h-4" />
통계
</button>
<button
onClick={() => setShowFilters(!showFilters)}
className={`px-4 py-2 rounded-lg flex items-center gap-2 ${
showFilters
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50'
} border transition-colors`}
>
<Filter className="w-4 h-4" />
필터
{showFilters ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
<button
onClick={clearAllLogs}
className="px-4 py-2 bg-red-600 text-white hover:bg-red-700 border border-red-600 rounded-lg flex items-center gap-2 transition-colors"
>
<Trash2 className="w-4 h-4" />
모든 로그 삭제
</button>
<button
onClick={() => { fetchLogs(); fetchStats(); }}
className="px-4 py-2 bg-white text-gray-600 hover:bg-gray-50 border rounded-lg flex items-center gap-2 transition-colors"
>
<RefreshCw className="w-4 h-4" />
새로고침
</button>
</div>
</div>
</div>
{/* Log Type Selector */}
<div className="mb-6 flex gap-2 flex-wrap">
<button
onClick={() => setLogType('data')}
className={`px-4 py-2 rounded-lg flex items-center gap-2 ${
logType === 'data'
? 'bg-purple-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50 border'
} transition-colors`}
>
<BarChart3 className="w-4 h-4" />
데이터 요청 로그
</button>
<button
onClick={() => setLogType('all')}
className={`px-4 py-2 rounded-lg ${
logType === 'all'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50 border'
} transition-colors`}
>
전체 로그
</button>
<button
onClick={() => setLogType('errors')}
className={`px-4 py-2 rounded-lg flex items-center gap-2 ${
logType === 'errors'
? 'bg-red-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50 border'
} transition-colors`}
>
<AlertTriangle className="w-4 h-4" />
에러 로그
</button>
<button
onClick={() => setLogType('success')}
className={`px-4 py-2 rounded-lg flex items-center gap-2 ${
logType === 'success'
? 'bg-green-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50 border'
} transition-colors`}
>
<CheckCircle className="w-4 h-4" />
성공 로그
</button>
</div>
{/* Statistics */}
{showStats && (
<div className="mb-6 grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">총 요청</p>
<p className="text-2xl font-bold text-gray-900">
{stats?.total_requests !== undefined ? stats.total_requests.toLocaleString() : 'Loading...'}
</p>
</div>
<Globe className="w-8 h-8 text-blue-600" />
</div>
</div>
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">성공</p>
<p className="text-2xl font-bold text-green-600">
{stats?.success_requests !== undefined ? stats.success_requests.toLocaleString() : 'Loading...'}
</p>
</div>
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
</div>
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">클라이언트 에러</p>
<p className="text-2xl font-bold text-yellow-600">
{stats?.client_error_requests !== undefined ? stats.client_error_requests.toLocaleString() : 'Loading...'}
</p>
</div>
<AlertCircle className="w-8 h-8 text-yellow-600" />
</div>
</div>
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">서버 에러</p>
<p className="text-2xl font-bold text-red-600">
{stats?.server_error_requests !== undefined ? stats.server_error_requests.toLocaleString() : 'Loading...'}
</p>
</div>
<XCircle className="w-8 h-8 text-red-600" />
</div>
</div>
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">평균 응답시간</p>
<p className="text-2xl font-bold text-gray-900">
{stats?.average_response_time_ms !== undefined ? formatResponseTime(stats.average_response_time_ms) : 'Loading...'}
</p>
</div>
<Clock className="w-8 h-8 text-orange-600" />
</div>
</div>
</div>
)}
{/* Filters */}
{showFilters && (
<div className="mb-6 bg-white p-4 rounded-lg border">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">시작 날짜</label>
<input
type="date"
value={filters.start_date}
onChange={(e) => setFilters({ ...filters, start_date: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">종료 날짜</label>
<input
type="date"
value={filters.end_date}
onChange={(e) => setFilters({ ...filters, end_date: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">HTTP 메서드</label>
<select
value={filters.method}
onChange={(e) => setFilters({ ...filters, method: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">전체</option>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
<option value="PATCH">PATCH</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">상태 코드</label>
<input
type="number"
placeholder="예: 200, 404, 500"
value={filters.status_code}
onChange={(e) => setFilters({ ...filters, status_code: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">엔드포인트</label>
<input
type="text"
placeholder="예: /api/v1/financial/*"
value={filters.endpoint}
onChange={(e) => setFilters({ ...filters, endpoint: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
)}
{/* Logs Table */}
<div className="bg-white rounded-lg border overflow-hidden">
{loading ? (
<div className="p-8 text-center">
<RefreshCw className="w-8 h-8 text-blue-600 animate-spin mx-auto mb-4" />
<p className="text-gray-600">로그를 불러오는 중...</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
상태
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
시간
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
메서드
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
엔드포인트
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
상태코드
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
응답시간
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
IP
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
상세
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{logs.map((log) => (
<React.Fragment key={log.id}>
<tr className={`hover:bg-gray-50 ${log.is_error ? 'bg-red-50' : ''}`}>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusIcon(log.status_code)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDateTime(log.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getMethodColor(log.method)}`}>
{log.method}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-900 max-w-xs truncate" title={log.path}>
{log.endpoint}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusCodeColor(log.status_code)}`}>
{log.status_code}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatResponseTime(log.response_time_ms)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{log.client_ip || 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<button
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
className="text-blue-600 hover:text-blue-800"
>
{expandedLog === log.id ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
</td>
</tr>
{expandedLog === log.id && (
<tr>
<td colSpan={8} className="px-6 py-4 bg-gray-50">
<div className="space-y-4">
{/* Request Information */}
<div>
<h4 className="text-sm font-medium text-gray-900 mb-2">요청 정보</h4>
<div className="bg-white p-3 rounded border text-sm text-gray-900">
<p className="text-gray-900"><span className="font-medium text-gray-700">Request ID:</span> {log.request_id}</p>
<p className="text-gray-900"><span className="font-medium text-gray-700">Full Path:</span> {log.path}</p>
<p className="text-gray-900"><span className="font-medium text-gray-700">User Agent:</span> {log.user_agent || 'N/A'}</p>
{/* Data Source Info for successful requests */}
{log.status_code >= 200 && log.status_code < 300 && log.headers?.['X-Data-Source'] && (
<div className="mt-2 p-2 bg-blue-50 border border-blue-200 rounded">
<p className="text-blue-900">
<span className="font-medium text-blue-700">데이터 소스:</span>
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{log.headers['X-Data-Source'] === 'database-cache' ? 'bg-green-100 text-green-800' :
log.headers['X-Data-Source'] === 'yfinance-fresh' ? 'bg-yellow-100 text-yellow-800' :
log.headers['X-Data-Source'] === 'yfinance-partial' ? 'bg-orange-100 text-orange-800' :
'bg-gray-100 text-gray-800'}">
{log.headers['X-Data-Source'] === 'database-cache' ? '🗄️ DB 캐시' :
log.headers['X-Data-Source'] === 'yfinance-fresh' ? '🔄 Yahoo Finance (신규)' :
log.headers['X-Data-Source'] === 'yfinance-partial' ? '📊 Yahoo Finance (부분)' :
log.headers['X-Data-Source']}
</span>
</p>
</div>
)}
{log.headers && Object.keys(log.headers).length > 0 && (
<div className="mt-2">
<span className="font-medium text-gray-700">Headers:</span>
<pre className="mt-1 bg-gray-50 p-2 rounded text-xs overflow-x-auto text-gray-800">
{JSON.stringify(log.headers, null, 2)}
</pre>
</div>
)}
{log.query_params && Object.keys(log.query_params).length > 0 && (
<div className="mt-2">
<span className="font-medium text-gray-700">Query Parameters:</span>
<pre className="mt-1 bg-gray-50 p-2 rounded text-xs overflow-x-auto text-gray-800">
{JSON.stringify(log.query_params, null, 2)}
</pre>
</div>
)}
{log.request_body && (
<div className="mt-2">
<span className="font-medium text-gray-700">Request Body:</span>
<pre className="mt-1 bg-gray-50 p-2 rounded text-xs overflow-x-auto text-gray-800">
{JSON.stringify(log.request_body, null, 2)}
</pre>
</div>
)}
</div>
</div>
{/* Error Information */}
{log.is_error && (log.error_message || log.status_code >= 400) && (
<div>
<h4 className="text-sm font-medium text-red-900 mb-2">에러 정보</h4>
<div className="bg-red-50 p-3 rounded border border-red-200 text-sm text-red-900">
<p className="text-red-900"><span className="font-medium text-red-700">Status Code:</span> {log.status_code}</p>
{log.error_type && (
<p className="text-red-900"><span className="font-medium text-red-700">Error Type:</span> {log.error_type}</p>
)}
{log.error_message && (
<p className="text-red-900"><span className="font-medium text-red-700">Error Message:</span> {log.error_message}</p>
)}
{/* Error Response Body - show raw response from error_detail */}
{log.error_detail && (
<div className="mt-2">
<span className="font-medium text-red-700">에러 응답:</span>
<pre className="mt-1 bg-white p-2 rounded text-xs overflow-x-auto text-red-800 max-h-48 overflow-y-auto">
{typeof log.error_detail === 'string' ? log.error_detail : JSON.stringify(log.error_detail, null, 2)}
</pre>
</div>
)}
{log.stack_trace && (
<div className="mt-2">
<span className="font-medium text-red-700">Stack Trace:</span>
<pre className="mt-1 bg-white p-2 rounded text-xs overflow-x-auto max-h-64 overflow-y-auto text-gray-800">
{log.stack_trace}
</pre>
</div>
)}
</div>
</div>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
{logs.length === 0 && (
<div className="p-8 text-center">
<Activity className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500">조건에 맞는 로그가 없습니다.</p>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
};
export default UnifiedLogViewer;

@ -0,0 +1,686 @@
// API 기본 설정
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:18001/api/v1';
// Fetch 기반 API 클라이언트
const createApiClient = () => {
const request = async (
endpoint: string,
options: RequestInit = {}
): Promise<Response> => {
const url = `${API_BASE_URL}${endpoint}`;
console.log(`🚀 API Request: ${options.method || 'GET'} ${url}`);
const config: RequestInit = {
signal: AbortSignal.timeout(30000),
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
};
try {
const response = await fetch(url, config);
console.log(`✅ API Response: ${response.status} ${endpoint}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response;
} catch (error) {
console.error('❌ API Error:', error);
throw error;
}
};
return {
get: async (endpoint: string) => {
const response = await request(endpoint, { method: 'GET' });
return { data: await response.json() };
},
post: async (endpoint: string, data?: any) => {
const response = await request(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
return { data: await response.json() };
},
patch: async (endpoint: string, data?: any) => {
const response = await request(endpoint, {
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined,
});
return { data: await response.json() };
},
delete: async (endpoint: string) => {
const response = await request(endpoint, { method: 'DELETE' });
return { data: await response.json() };
}
};
};
export const api = createApiClient();
// 타입 정의
export interface Company {
ticker: string;
name: string;
cik?: string;
sector?: string;
industry?: string;
business_description?: string;
}
export interface FinancialData {
period_date: string;
period_type: string;
filing_type?: string;
revenue?: number;
gross_profit?: number;
operating_income?: number;
net_income?: number;
eps?: number;
total_assets?: number;
total_equity?: number;
total_debt?: number;
cash?: number;
shares_outstanding?: number;
operating_cash_flow?: number;
free_cash_flow?: number;
capex?: number;
data_source: string;
is_estimated: boolean;
// Calculated metrics
pe_ratio?: number;
pb_ratio?: number;
ps_ratio?: number;
roe?: number;
roa?: number;
gross_margin?: number;
operating_margin?: number;
net_margin?: number;
debt_to_equity?: number;
debt_to_assets?: number;
ocf_margin?: number;
fcf_margin?: number;
market_cap?: number;
}
export interface PriceData {
date: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
adj_close?: number;
}
export interface FinancialDataRequest {
ticker: string;
start_date: string;
end_date: string;
period_type?: 'quarterly' | 'annual' | 'all';
include_metrics?: boolean;
force_refresh?: boolean;
}
export interface PriceDataRequest {
ticker: string;
start_date: string;
end_date: string;
interval?: '1d' | '1wk' | '1mo';
}
export interface FinancialDataResponse {
company: Company;
financial_data: FinancialData[];
metadata: {
request_id: string;
data_points: number;
period_type: string;
date_range: {
start: string;
end: string;
};
last_updated: string;
};
}
export interface PriceDataResponse {
ticker: string;
data: PriceData[];
interval: string;
}
export interface DatabaseStats {
companies: {
total: number;
with_financial_data: number;
with_price_data: number;
};
financial_data: {
total_records: number;
real_data: number;
estimated_data: number;
date_range: {
earliest: string;
latest: string;
};
by_source: {
[key: string]: number;
};
};
price_data: {
total_records: number;
date_range: {
earliest: string;
latest: string;
};
tickers: string[];
};
calculated_metrics: {
total_records: number;
date_range: {
earliest: string;
latest: string;
};
};
}
// News and Social Media Types
export interface NewsArticle {
title: string;
summary?: string;
content?: string;
url: string;
source: string;
published_at?: string;
author?: string;
sentiment_score?: number;
relevance_score?: number;
image_url?: string;
tags: string[];
}
export interface SocialPost {
title: string;
content: string;
url: string;
platform: string;
author: string;
published_at?: string;
score?: number;
comments_count?: number;
upvotes?: number;
downvotes?: number;
sentiment_score?: number;
subreddit?: string;
}
export interface NewsSocialRequest {
ticker: string;
days_back?: number;
max_articles?: number;
max_social_posts?: number;
include_social?: boolean;
}
export interface NewsSocialResponse {
ticker: string;
retrieved_at: string;
news: {
total_articles: number;
sources: {
yahoo_finance: number;
newsapi: number;
};
articles: NewsArticle[];
};
social_media: {
total_posts: number;
platforms: {
reddit: number;
};
posts: SocialPost[];
};
summary: {
total_items: number;
time_range_days: number;
oldest_item?: string;
newest_item?: string;
};
}
// API 함수들
export const stockApi = {
// 재무 데이터 조회
getFinancialData: async (request: FinancialDataRequest): Promise<FinancialDataResponse> => {
const response = await api.post('/financial/data', request);
return response.data;
},
// 주가 데이터 조회
getPriceData: async (request: PriceDataRequest): Promise<PriceDataResponse> => {
const response = await api.post('/price/data', request);
return response.data;
},
// 데이터베이스 통계 조회
getDatabaseStats: async (): Promise<DatabaseStats> => {
const response = await api.get('/database/stats');
return response.data;
},
// ETF snapshots list
getEtfSnapshots: async (params?: { ticker?: string; start_date?: string; end_date?: string; limit?: number; offset?: number }) => {
const qp = new URLSearchParams();
if (params?.ticker) qp.set('ticker', params.ticker);
if (params?.start_date) qp.set('start_date', params.start_date);
if (params?.end_date) qp.set('end_date', params.end_date);
if (params?.limit) qp.set('limit', String(params.limit));
if (params?.offset) qp.set('offset', String(params.offset));
const suffix = qp.toString() ? `?${qp.toString()}` : '';
const { data } = await api.get(`/database/etf/snapshots${suffix}`);
return data as { results: Array<{ id: string; ticker: string; snapshot_date: string; source?: string; cik?: string; holdings_count: number }>; count: number };
},
// Single ETF snapshot by id
getEtfSnapshotById: async (snapshotId: string) => {
const { data } = await api.get(`/database/etf/snapshot/${snapshotId}`);
return data as { snapshot: { id: string; ticker: string; snapshot_date: string; source?: string; cik?: string; filing_accession?: string; xml_url?: string }; holdings: Array<{ name?: string; cusip?: string; ticker?: string; shares?: number; value?: number; percentage?: number }>; holdings_count: number };
},
// Financial records list
getFinancialRecords: async (params?: { ticker?: string; period_type?: 'quarterly' | 'annual'; start_date?: string; end_date?: string; limit?: number; offset?: number }) => {
const qp = new URLSearchParams();
if (params?.ticker) qp.set('ticker', params.ticker);
if (params?.period_type) qp.set('period_type', params.period_type);
if (params?.start_date) qp.set('start_date', params.start_date);
if (params?.end_date) qp.set('end_date', params.end_date);
if (params?.limit) qp.set('limit', String(params.limit));
if (params?.offset) qp.set('offset', String(params.offset));
const suffix = qp.toString() ? `?${qp.toString()}` : '';
const { data } = await api.get(`/database/financial/records${suffix}`);
return data as { results: Array<{ ticker: string; period_date: string; period_type: string; data_source: string; is_estimated: boolean; revenue?: number; net_income?: number }>; count: number };
},
// 사용 가능한 종목 목록 조회
getAvailableTickers: async (): Promise<string[]> => {
const response = await api.get('/tickers');
return response.data.tickers || [];
},
// 회사 정보 조회
getCompanyInfo: async (ticker: string): Promise<Company> => {
const response = await api.get(`/company/${ticker}`);
return response.data;
},
// API 상태 확인
getHealthCheck: async (): Promise<{ status: string; timestamp: string }> => {
const response = await api.get('/health');
return response.data;
},
// 뉴스 & 소셜 미디어 데이터 조회
getNewsSocialData: async (params: NewsSocialRequest): Promise<NewsSocialResponse> => {
const { ticker, days_back = 7, max_articles = 20, max_social_posts = 15, include_social = true } = params;
const queryParams = new URLSearchParams({
days_back: days_back.toString(),
max_articles: max_articles.toString(),
max_social_posts: max_social_posts.toString(),
include_social: include_social.toString(),
});
const response = await api.get(`/news/${ticker}?${queryParams}`);
return response.data;
},
// 뉴스만 조회 (더 빠른 응답)
getNewsOnly: async (params: Pick<NewsSocialRequest, 'ticker' | 'days_back' | 'max_articles'>) => {
const { ticker, days_back = 7, max_articles = 30 } = params;
const queryParams = new URLSearchParams({
days_back: days_back.toString(),
max_articles: max_articles.toString(),
});
const response = await api.get(`/news/${ticker}/news-only?${queryParams}`);
return response.data;
},
// 소셜 미디어만 조회
getSocialOnly: async (params: Pick<NewsSocialRequest, 'ticker' | 'days_back' | 'max_social_posts'>) => {
const { ticker, days_back = 7, max_social_posts = 20 } = params;
const queryParams = new URLSearchParams({
days_back: days_back.toString(),
max_social_posts: max_social_posts.toString(),
});
const response = await api.get(`/news/${ticker}/social-only?${queryParams}`);
return response.data;
},
};
// FRED API Types
export interface FredUsageStats {
daily_limit: number;
used_today: number;
remaining_today: number;
usage_percentage: number;
can_make_requests: boolean;
daily_stats: Array<{
date: string;
total_calls: number;
successful_calls: number;
total_records: number;
success_rate: number;
}>;
endpoint_stats: Array<{
endpoint: string;
call_count: number;
}>;
cache_stats: {
cached_series: number;
cached_observations: number;
cache_duration_hours: number;
};
proxy_info: {
mode: string;
cache_duration_hours: number;
supported_endpoints: string;
permanent_storage: boolean;
smart_caching: boolean;
};
}
export interface FredSeriesData {
id: string;
title: string;
units: string;
frequency: string;
last_updated: string;
popularity?: number;
notes?: string;
cached?: boolean;
cached_at?: string;
}
export interface FredObservation {
date: string;
value: string;
realtime_start?: string;
realtime_end?: string;
}
export interface FredObservationsData {
series_id: string;
observations: FredObservation[];
count: number;
cached?: boolean;
cached_at?: string;
}
export interface FredEndpointsData {
series_endpoints: string[];
category_endpoints: string[];
release_endpoints: string[];
source_endpoints: string[];
tag_endpoints: string[];
other_endpoints: string[];
note: string;
}
export interface FredProxyRequest {
endpoint: string;
params?: {
series_id?: string;
category_id?: number;
release_id?: number;
source_id?: number;
search_text?: string;
limit?: number;
offset?: number;
observation_start?: string;
observation_end?: string;
realtime_start?: string;
realtime_end?: string;
order_by?: string;
sort_order?: 'asc' | 'desc';
frequency?: string;
aggregation_method?: string;
[key: string]: any;
};
force_refresh?: boolean;
bypass_limit_check?: boolean;
}
// FRED API 함수들
export const fredApi = {
// FRED API 사용량 통계 조회
getUsageStats: async (days: number = 7, useProxyStats: boolean = true): Promise<FredUsageStats> => {
const queryParams = new URLSearchParams({
days: days.toString(),
use_proxy_stats: useProxyStats.toString(),
});
const response = await api.get(`/fred/stats/usage?${queryParams}`);
return response.data.data;
},
// FRED 지원 엔드포인트 목록 조회
getSupportedEndpoints: async (): Promise<FredEndpointsData> => {
const response = await api.get('/fred/endpoints');
return response.data.data;
},
// FRED API 프록시 요청 (범용)
proxyRequest: async (request: FredProxyRequest): Promise<any> => {
const { endpoint, params = {}, force_refresh = false, bypass_limit_check = false } = request;
const queryParams = new URLSearchParams();
// 기본 파라미터들 추가
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
queryParams.set(key, value.toString());
}
});
// 제어 파라미터들 추가
if (force_refresh) queryParams.set('force_refresh', 'true');
if (bypass_limit_check) queryParams.set('bypass_limit_check', 'true');
const response = await api.get(`/fred/proxy/${endpoint}?${queryParams}`);
return response.data;
},
// 인기 경제 지표 시리즈 조회 (미리 정의된)
getPopularSeries: async (): Promise<FredSeriesData[]> => {
const popularSeriesIds = ['GDP', 'UNRATE', 'FEDFUNDS', 'CPIAUCSL', 'PAYEMS', 'HOUST', 'INDPRO'];
const promises = popularSeriesIds.map(async (seriesId) => {
try {
const queryParams = new URLSearchParams();
queryParams.set('series_id', seriesId);
const response = await api.get(`/fred/proxy/series?${queryParams}`);
if (response.data.success && response.data.data.seriess && response.data.data.seriess.length > 0) {
return response.data.data.seriess[0];
}
return null;
} catch (error) {
console.error(`Failed to fetch series ${seriesId}:`, error);
return null;
}
});
const results = await Promise.all(promises);
return results.filter(series => series !== null);
},
// 시리즈 검색
searchSeries: async (searchText: string, limit: number = 25): Promise<FredSeriesData[]> => {
const queryParams = new URLSearchParams();
queryParams.set('search_text', searchText);
queryParams.set('limit', limit.toString());
const response = await api.get(`/fred/proxy/series/search?${queryParams}`);
if (response.data.success && response.data.data.seriess) {
return response.data.data.seriess;
}
return [];
},
// 시리즈 관측값 조회
getSeriesObservations: async (
seriesId: string,
limit?: number,
observationStart?: string,
observationEnd?: string
): Promise<FredObservationsData> => {
const queryParams = new URLSearchParams();
queryParams.set('series_id', seriesId);
if (limit) queryParams.set('limit', limit.toString());
if (observationStart) queryParams.set('observation_start', observationStart);
if (observationEnd) queryParams.set('observation_end', observationEnd);
queryParams.set('sort_order', 'desc');
const response = await api.get(`/fred/proxy/series/observations?${queryParams}`);
if (response.data.success && response.data.data.observations) {
return {
series_id: seriesId,
observations: response.data.data.observations,
count: response.data.data.count || response.data.data.observations.length,
cached: response.data.metadata?.cached,
cached_at: response.data.metadata?.cached_at
};
}
return {
series_id: seriesId,
observations: [],
count: 0
};
},
};
// Admin API 함수들 (로그 관리)
export const adminApi = {
getRequestLogs: async (params?: Record<string, string>) => {
const qp = new URLSearchParams(params);
const { data } = await api.get(`/admin/requests/logs?${qp}`);
return data;
},
getErrorLogs: async (params?: Record<string, string>) => {
const qp = new URLSearchParams(params);
const { data } = await api.get(`/admin/errors/logs?${qp}`);
return data;
},
getRequestStats: async (params?: Record<string, string>) => {
const qp = new URLSearchParams(params);
const { data } = await api.get(`/admin/requests/stats?${qp}`);
return data;
},
getErrorStats: async (params?: Record<string, string>) => {
const qp = new URLSearchParams(params);
const { data } = await api.get(`/admin/errors/stats?${qp}`);
return data;
},
deleteRequestLogs: async (params?: Record<string, string>) => {
const qp = new URLSearchParams(params);
const { data } = await api.delete(`/admin/requests/logs?${qp}`);
return data;
},
deleteErrorLogs: async (params?: Record<string, string>) => {
const qp = new URLSearchParams(params);
const { data } = await api.delete(`/admin/errors/logs?${qp}`);
return data;
},
getErrorByRequestId: async (requestId: string) => {
const { data } = await api.get(`/admin/errors/by-request/${requestId}`);
return data;
},
resolveError: async (logId: number, isResolved: boolean, notes?: string) => {
const { data } = await api.patch(`/admin/errors/logs/${logId}`, {
is_resolved: isResolved,
resolution_notes: notes || undefined,
});
return data;
},
};
// 유틸리티 함수들
export const formatCurrency = (value: number | undefined): string => {
if (value === undefined || value === null) return 'N/A';
if (Math.abs(value) >= 1e12) {
return `$${(value / 1e12).toFixed(1)}T`;
} else if (Math.abs(value) >= 1e9) {
return `$${(value / 1e9).toFixed(1)}B`;
} else if (Math.abs(value) >= 1e6) {
return `$${(value / 1e6).toFixed(1)}M`;
} else if (Math.abs(value) >= 1e3) {
return `$${(value / 1e3).toFixed(1)}K`;
} else {
return `$${value.toFixed(2)}`;
}
};
export const formatNumber = (value: number | undefined): string => {
if (value === undefined || value === null) return 'N/A';
if (Math.abs(value) >= 1e9) {
return `${(value / 1e9).toFixed(2)}B`;
} else if (Math.abs(value) >= 1e6) {
return `${(value / 1e6).toFixed(2)}M`;
} else if (Math.abs(value) >= 1e3) {
return `${(value / 1e3).toFixed(2)}K`;
} else {
return value.toFixed(2);
}
};
export const formatPercentage = (value: number | undefined): string => {
if (value === undefined || value === null) return 'N/A';
return `${(value * 100).toFixed(2)}%`;
};
export const formatDate = (date: string, format?: string): string => {
const dateObj = new Date(date);
if (format === 'MM/dd') {
return dateObj.toLocaleDateString('en-US', {
month: '2-digit',
day: '2-digit',
});
}
if (format === 'MM/yy') {
return dateObj.toLocaleDateString('en-US', {
month: '2-digit',
year: '2-digit',
});
}
return dateObj.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
};

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

@ -0,0 +1,40 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'standalone',
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:18001/api/v1',
},
webpack: (config) => {
config.resolve.fallback = {
...config.resolve.fallback,
"http": false,
"https": false,
"url": false,
};
return config;
},
async rewrites() {
return [
{
source: '/api/v1/:path*',
destination: 'http://stock_oracle_api:18000/api/v1/:path*',
},
];
},
async headers() {
return [
{
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
{ key: 'Access-Control-Allow-Origin', value: '*' },
{ key: 'Access-Control-Allow-Methods', value: 'GET,OPTIONS,PATCH,DELETE,POST,PUT' },
{ key: 'Access-Control-Allow-Headers', value: 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version' },
]
}
];
}
};
module.exports = nextConfig;

File diff suppressed because it is too large Load Diff

@ -0,0 +1,44 @@
{
"name": "stock-oracle-frontend",
"version": "1.0.0",
"description": "Stock Oracle Web Frontend - 주식 데이터 분석 대시보드",
"main": "index.js",
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint",
"export": "next export"
},
"dependencies": {
"@babel/runtime": "^7.26.0",
"autoprefixer": "^10.4.16",
"date-fns": "^2.30.0",
"lucide-react": "^0.294.0",
"next": "^15.4.6",
"plotly.js": "^2.35.3",
"postcss": "^8.4.31",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-plotly.js": "^2.6.0",
"react-query": "^3.39.3",
"tailwindcss": "^3.3.0"
},
"devDependencies": {
"@types/node": "^20.8.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"eslint": "^8.51.0",
"eslint-config-next": "^15.0.0",
"typescript": "^5.2.0"
},
"keywords": [
"stock",
"finance",
"dashboard",
"react",
"nextjs"
],
"author": "Stock Oracle Team",
"license": "MIT"
}

@ -0,0 +1,21 @@
import '@/styles/globals.css'
import type { AppProps } from 'next/app'
import { QueryClient, QueryClientProvider } from 'react-query'
import { useState } from 'react'
export default function App({ Component, pageProps }: AppProps) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 2,
},
},
}))
return (
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
)
}

@ -0,0 +1,15 @@
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html lang="ko">
<Head>
<meta charSet="utf-8" />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}

@ -0,0 +1,37 @@
import { NextPageContext } from 'next';
import Layout from '@/components/Layout';
interface ErrorPageProps {
statusCode: number;
}
function ErrorPage({ statusCode }: ErrorPageProps) {
const title = statusCode === 404 ? 'Page Not Found' : 'Server Error';
const message =
statusCode === 404
? '요청하신 페이지를 찾을 수 없습니다.'
: '서버에서 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
return (
<Layout title={`${statusCode} - Stock Oracle`}>
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
<h1 className="text-6xl font-bold text-gray-300 mb-4">{statusCode}</h1>
<h2 className="text-2xl font-semibold text-gray-900 mb-2">{title}</h2>
<p className="text-gray-600 mb-8">{message}</p>
<a
href="/"
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
홈으로 돌아가기
</a>
</div>
</Layout>
);
}
ErrorPage.getInitialProps = ({ res, err }: NextPageContext) => {
const statusCode = res ? res.statusCode : err ? err.statusCode ?? 500 : 404;
return { statusCode };
};
export default ErrorPage;

@ -0,0 +1,144 @@
import React, { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { stockApi } from '@/lib/api';
import { List, Database as DBIcon, Layers, Calendar } from 'lucide-react';
import Layout from '@/components/Layout';
const DatabaseStats = dynamic(() => import('@/components/DatabaseStats'), {
ssr: false,
loading: () => (
<div className="animate-pulse space-y-4">
<div className="h-6 bg-gray-200 rounded w-1/3"></div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="h-32 bg-gray-200 rounded"></div>
<div className="h-32 bg-gray-200 rounded"></div>
<div className="h-32 bg-gray-200 rounded"></div>
</div>
</div>
),
});
const DatabasePage: React.FC = () => {
const [tab, setTab] = useState<'stats' | 'etf' | 'financial'>('stats');
const [etf, setEtf] = useState<{loading:boolean; items:any[]; total:number}>({loading:false, items:[], total:0});
const [fin, setFin] = useState<{loading:boolean; items:any[]; total:number}>({loading:false, items:[], total:0});
const loadEtf = async () => {
setEtf(s => ({...s, loading:true}));
try {
const res = await stockApi.getEtfSnapshots({ limit: 25 });
setEtf({loading:false, items:res.results, total:res.count});
} catch {
setEtf({loading:false, items:[], total:0});
}
};
const loadFin = async () => {
setFin(s => ({...s, loading:true}));
try {
const res = await stockApi.getFinancialRecords({ limit: 50 });
setFin({loading:false, items:res.results, total:res.count});
} catch {
setFin({loading:false, items:[], total:0});
}
};
useEffect(() => {
if (tab === 'etf') loadEtf();
if (tab === 'financial') loadFin();
}, [tab]);
return (
<Layout title="Database - Stock Oracle">
<div className="mb-6 flex items-center space-x-2">
<button onClick={() => setTab('stats')} className={`px-3 py-2 rounded-md text-sm ${tab==='stats'?'bg-blue-600 text-white':'bg-gray-100 text-gray-700'}`}>
개요
</button>
<button onClick={() => setTab('etf')} className={`px-3 py-2 rounded-md text-sm ${tab==='etf'?'bg-blue-600 text-white':'bg-gray-100 text-gray-700'}`}>
ETF 스냅샷
</button>
<button onClick={() => setTab('financial')} className={`px-3 py-2 rounded-md text-sm ${tab==='financial'?'bg-blue-600 text-white':'bg-gray-100 text-gray-700'}`}>
재무 레코드
</button>
</div>
{tab==='stats' && <DatabaseStats />}
{tab==='etf' && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center"><Layers className="h-5 w-5 mr-2"/>저장된 ETF 스냅샷</h3>
{etf.loading ? (
<p className="text-gray-500">로딩 중...</p>
) : etf.items.length===0 ? (
<p className="text-gray-500">저장된 스냅샷이 없습니다.</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm text-gray-900">
<thead>
<tr className="text-left text-gray-800 border-b">
<th className="py-2 pr-4">Snapshot ID</th>
<th className="py-2 pr-4">Ticker</th>
<th className="py-2 pr-4">Date</th>
<th className="py-2 pr-4">Source</th>
<th className="py-2 pr-4">Holdings</th>
</tr>
</thead>
<tbody>
{etf.items.map((s:any) => (
<tr key={s.id} className="border-b hover:bg-gray-50 text-gray-900">
<td className="py-2 pr-4 font-mono text-xs break-all">{s.id}</td>
<td className="py-2 pr-4">{s.ticker}</td>
<td className="py-2 pr-4 flex items-center"><Calendar className="h-4 w-4 mr-1"/>{new Date(s.snapshot_date).toLocaleDateString('ko-KR')}</td>
<td className="py-2 pr-4">{s.source || '-'}</td>
<td className="py-2 pr-4">{s.holdings_count}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{tab==='financial' && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center"><DBIcon className="h-5 w-5 mr-2"/>저장된 재무 레코드</h3>
{fin.loading ? (
<p className="text-gray-500">로딩 중...</p>
) : fin.items.length===0 ? (
<p className="text-gray-500">레코드가 없습니다.</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm text-gray-900">
<thead>
<tr className="text-left text-gray-800 border-b">
<th className="py-2 pr-4">Ticker</th>
<th className="py-2 pr-4">Period</th>
<th className="py-2 pr-4">Type</th>
<th className="py-2 pr-4">Revenue</th>
<th className="py-2 pr-4">Net Income</th>
<th className="py-2 pr-4">Source</th>
</tr>
</thead>
<tbody>
{fin.items.map((r:any) => (
<tr key={`${r.ticker}-${r.period_date}-${r.period_type}`} className="border-b hover:bg-gray-50 text-gray-900">
<td className="py-2 pr-4">{r.ticker}</td>
<td className="py-2 pr-4">{new Date(r.period_date).toLocaleDateString('ko-KR')}</td>
<td className="py-2 pr-4">{r.period_type}</td>
<td className="py-2 pr-4">{r.revenue?.toLocaleString() ?? '-'}</td>
<td className="py-2 pr-4">{r.net_income?.toLocaleString() ?? '-'}</td>
<td className="py-2 pr-4">{r.data_source}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</Layout>
);
};
export default DatabasePage;

@ -0,0 +1,13 @@
import React from 'react';
import Layout from '../components/Layout';
import ErrorLogViewer from '../components/ErrorLogViewer';
const ErrorsPage: React.FC = () => {
return (
<Layout title="에러 로그 - Stock Oracle">
<ErrorLogViewer />
</Layout>
);
};
export default ErrorsPage;

@ -0,0 +1,516 @@
import React, { useState, useEffect } from 'react';
import Layout from '../components/Layout';
import { fredApi, FredUsageStats, FredSeriesData, FredObservationsData } from '../lib/api';
interface QuotaDisplayProps {
stats: FredUsageStats;
isLoading: boolean;
}
const QuotaDisplay: React.FC<QuotaDisplayProps> = ({ stats, isLoading }) => {
if (isLoading) {
return (
<div className="bg-white rounded-lg shadow p-6 mb-6">
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
<div className="h-8 bg-gray-200 rounded w-1/2"></div>
</div>
</div>
);
}
const getQuotaColor = (percentage: number) => {
if (percentage < 50) return 'bg-green-500';
if (percentage < 80) return 'bg-yellow-500';
return 'bg-red-500';
};
const getQuotaTextColor = (percentage: number) => {
if (percentage < 50) return 'text-green-600';
if (percentage < 80) return 'text-yellow-600';
return 'text-red-600';
};
return (
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">📊 FRED API Daily Quota</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div className="bg-gray-50 rounded-lg p-4">
<div className="text-sm text-gray-500">Used Today</div>
<div className={`text-2xl font-bold ${getQuotaTextColor(stats.usage_percentage)}`}>
{stats.used_today}
</div>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<div className="text-sm text-gray-500">Remaining</div>
<div className="text-2xl font-bold text-blue-600">
{stats.remaining_today}
</div>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<div className="text-sm text-gray-500">Daily Limit</div>
<div className="text-2xl font-bold text-gray-900">
{stats.daily_limit}
</div>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<div className="text-sm text-gray-500">Usage %</div>
<div className={`text-2xl font-bold ${getQuotaTextColor(stats.usage_percentage)}`}>
{stats.usage_percentage.toFixed(1)}%
</div>
</div>
</div>
{/* Progress Bar */}
<div className="mb-4">
<div className="flex justify-between text-sm text-gray-600 mb-1">
<span>API Usage Progress</span>
<span>{stats.used_today} / {stats.daily_limit}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${getQuotaColor(stats.usage_percentage)}`}
style={{ width: `${Math.min(stats.usage_percentage, 100)}%` }}
></div>
</div>
</div>
{/* Status */}
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${stats.can_make_requests ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span className={`text-sm font-medium ${stats.can_make_requests ? 'text-green-600' : 'text-red-600'}`}>
{stats.can_make_requests ? 'API Available' : 'Daily Limit Reached'}
</span>
</div>
</div>
);
};
interface CacheStatsProps {
stats: FredUsageStats;
}
const CacheStats: React.FC<CacheStatsProps> = ({ stats }) => {
return (
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">💾 Database Cache Statistics</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-blue-50 rounded-lg p-4">
<div className="text-sm text-blue-600">Cached Series</div>
<div className="text-2xl font-bold text-blue-900">
{stats.cache_stats.cached_series.toLocaleString()}
</div>
<div className="text-xs text-blue-500 mt-1">Economic data series</div>
</div>
<div className="bg-green-50 rounded-lg p-4">
<div className="text-sm text-green-600">Cached Observations</div>
<div className="text-2xl font-bold text-green-900">
{stats.cache_stats.cached_observations.toLocaleString()}
</div>
<div className="text-xs text-green-500 mt-1">Historical data points</div>
</div>
<div className="bg-purple-50 rounded-lg p-4">
<div className="text-sm text-purple-600">Cache Duration</div>
<div className="text-2xl font-bold text-purple-900">
{stats.cache_stats.cache_duration_hours}h
</div>
<div className="text-xs text-purple-500 mt-1">Auto-refresh period</div>
</div>
</div>
<div className="mt-4 p-4 bg-gray-50 rounded-lg">
<h4 className="font-medium text-gray-900 mb-2">🔧 Proxy Information</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">Mode:</span>
<span className="ml-2 font-medium">{stats.proxy_info.mode}</span>
</div>
<div>
<span className="text-gray-600">Smart Caching:</span>
<span className="ml-2 font-medium">{stats.proxy_info.smart_caching ? 'Enabled' : 'Disabled'}</span>
</div>
<div>
<span className="text-gray-600">Permanent Storage:</span>
<span className="ml-2 font-medium">{stats.proxy_info.permanent_storage ? 'Yes' : 'No'}</span>
</div>
<div>
<span className="text-gray-600">Supported Endpoints:</span>
<span className="ml-2 font-medium">{stats.proxy_info.supported_endpoints}</span>
</div>
</div>
</div>
</div>
);
};
interface EndpointStatsProps {
stats: FredUsageStats;
}
const EndpointStats: React.FC<EndpointStatsProps> = ({ stats }) => {
const topEndpoints = stats.endpoint_stats.slice(0, 10);
return (
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">🔥 Most Used Endpoints</h3>
{topEndpoints.length > 0 ? (
<div className="space-y-3">
{topEndpoints.map((endpoint, index) => (
<div key={endpoint.endpoint} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-6 h-6 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-xs font-bold">
{index + 1}
</div>
<code className="text-sm font-mono bg-gray-200 px-2 py-1 rounded">
{endpoint.endpoint}
</code>
</div>
<div className="text-right">
<div className="text-lg font-bold text-gray-900">{endpoint.call_count}</div>
<div className="text-xs text-gray-500">calls</div>
</div>
</div>
))}
</div>
) : (
<div className="text-gray-500 text-center py-8">
No endpoint usage data available
</div>
)}
</div>
);
};
interface PopularSeriesProps {
series: FredSeriesData[];
isLoading: boolean;
onSelectSeries: (seriesId: string) => void;
}
const PopularSeries: React.FC<PopularSeriesProps> = ({ series, isLoading, onSelectSeries }) => {
if (isLoading) {
return (
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">📈 Popular Economic Indicators</h3>
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="animate-pulse p-4 bg-gray-50 rounded-lg">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
</div>
))}
</div>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">📈 Popular Economic Indicators</h3>
<div className="space-y-2">
{series.map((seriesData) => (
<button
key={seriesData.id}
onClick={() => onSelectSeries(seriesData.id)}
className="w-full text-left p-4 bg-gray-50 hover:bg-blue-50 rounded-lg transition-colors border border-transparent hover:border-blue-200"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="font-medium text-gray-900 flex items-center gap-2">
<code className="text-sm bg-gray-200 px-2 py-1 rounded">{seriesData.id}</code>
{seriesData.cached && (
<span className="text-xs bg-green-100 text-green-600 px-2 py-1 rounded">Cached</span>
)}
</div>
<div className="text-sm text-gray-600 mt-1 line-clamp-2">{seriesData.title}</div>
<div className="text-xs text-gray-500 mt-1">
{seriesData.frequency} • {seriesData.units}
</div>
</div>
<div className="text-xs text-gray-400 ml-4">
Updated: {new Date(seriesData.last_updated).toLocaleDateString()}
</div>
</div>
</button>
))}
</div>
</div>
);
};
interface SeriesSearchProps {
onSelectSeries: (seriesId: string) => void;
}
const SeriesSearch: React.FC<SeriesSearchProps> = ({ onSelectSeries }) => {
const [searchTerm, setSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState<FredSeriesData[]>([]);
const [isSearching, setIsSearching] = useState(false);
const handleSearch = async () => {
if (!searchTerm.trim()) return;
setIsSearching(true);
try {
const results = await fredApi.searchSeries(searchTerm, 15);
setSearchResults(results);
} catch (error) {
console.error('Search failed:', error);
setSearchResults([]);
} finally {
setIsSearching(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
};
return (
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">🔍 Search Economic Data</h3>
<div className="flex gap-2 mb-4">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Search for economic indicators (e.g., unemployment, inflation, GDP)"
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
onClick={handleSearch}
disabled={!searchTerm.trim() || isSearching}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSearching ? 'Searching...' : 'Search'}
</button>
</div>
{searchResults.length > 0 && (
<div className="space-y-2 max-h-96 overflow-y-auto">
{searchResults.map((seriesData) => (
<button
key={seriesData.id}
onClick={() => onSelectSeries(seriesData.id)}
className="w-full text-left p-3 bg-gray-50 hover:bg-blue-50 rounded-lg transition-colors border border-transparent hover:border-blue-200"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="font-medium text-gray-900">
<code className="text-sm bg-gray-200 px-2 py-1 rounded mr-2">{seriesData.id}</code>
</div>
<div className="text-sm text-gray-600 mt-1 line-clamp-2">{seriesData.title}</div>
<div className="text-xs text-gray-500 mt-1">
{seriesData.frequency} • {seriesData.units}
</div>
</div>
</div>
</button>
))}
</div>
)}
{searchTerm && searchResults.length === 0 && !isSearching && (
<div className="text-gray-500 text-center py-4">
No results found for "{searchTerm}"
</div>
)}
</div>
);
};
interface SeriesDataDisplayProps {
seriesId: string;
observations: FredObservationsData | null;
isLoading: boolean;
onClose: () => void;
}
const SeriesDataDisplay: React.FC<SeriesDataDisplayProps> = ({ seriesId, observations, isLoading, onClose }) => {
if (isLoading) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<div className="h-6 bg-gray-200 rounded w-1/3 animate-pulse"></div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">✕</button>
</div>
<div className="space-y-2">
{[...Array(10)].map((_, i) => (
<div key={i} className="flex justify-between items-center p-2 animate-pulse">
<div className="h-4 bg-gray-200 rounded w-24"></div>
<div className="h-4 bg-gray-200 rounded w-16"></div>
</div>
))}
</div>
</div>
);
}
if (!observations) return null;
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
📊 <code className="text-sm bg-gray-200 px-2 py-1 rounded">{seriesId}</code>
{observations.cached && (
<span className="text-xs bg-green-100 text-green-600 px-2 py-1 rounded">From Cache</span>
)}
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-xl"
>
✕
</button>
</div>
<div className="mb-4 text-sm text-gray-600">
{observations.count} observations • Last 20 shown
{observations.cached_at && (
<span className="ml-2">• Cached: {new Date(observations.cached_at).toLocaleString()}</span>
)}
</div>
<div className="max-h-96 overflow-y-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-4 py-2 text-left">Date</th>
<th className="px-4 py-2 text-right">Value</th>
</tr>
</thead>
<tbody>
{observations.observations.slice(0, 20).map((obs, index) => (
<tr key={index} className="border-t border-gray-100">
<td className="px-4 py-2 text-gray-900">{obs.date}</td>
<td className="px-4 py-2 text-right font-mono">
{obs.value === '.' ? 'N/A' : parseFloat(obs.value).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
const FredPage: React.FC = () => {
const [stats, setStats] = useState<FredUsageStats | null>(null);
const [popularSeries, setPopularSeries] = useState<FredSeriesData[]>([]);
const [selectedSeriesId, setSelectedSeriesId] = useState<string | null>(null);
const [seriesObservations, setSeriesObservations] = useState<FredObservationsData | null>(null);
const [isLoadingStats, setIsLoadingStats] = useState(true);
const [isLoadingPopular, setIsLoadingPopular] = useState(true);
const [isLoadingObservations, setIsLoadingObservations] = useState(false);
// Load initial data
useEffect(() => {
const loadInitialData = async () => {
try {
// Load usage stats
const statsData = await fredApi.getUsageStats(7, true);
setStats(statsData);
setIsLoadingStats(false);
// Load popular series
const popularData = await fredApi.getPopularSeries();
setPopularSeries(popularData);
setIsLoadingPopular(false);
} catch (error) {
console.error('Failed to load initial data:', error);
setIsLoadingStats(false);
setIsLoadingPopular(false);
}
};
loadInitialData();
}, []);
// Load series observations when selected
const handleSelectSeries = async (seriesId: string) => {
setSelectedSeriesId(seriesId);
setIsLoadingObservations(true);
try {
const observationsData = await fredApi.getSeriesObservations(seriesId, 50);
setSeriesObservations(observationsData);
} catch (error) {
console.error('Failed to load series observations:', error);
setSeriesObservations(null);
} finally {
setIsLoadingObservations(false);
}
};
const handleCloseSeriesData = () => {
setSelectedSeriesId(null);
setSeriesObservations(null);
};
return (
<Layout>
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">🏦 FRED Economic Data</h1>
<div className="text-sm text-gray-500">
Federal Reserve Economic Data (FRED) API Access
</div>
</div>
{/* Quota Display */}
{stats && <QuotaDisplay stats={stats} isLoading={isLoadingStats} />}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column */}
<div>
{/* Popular Series */}
<PopularSeries
series={popularSeries}
isLoading={isLoadingPopular}
onSelectSeries={handleSelectSeries}
/>
{/* Search */}
<SeriesSearch onSelectSeries={handleSelectSeries} />
</div>
{/* Right Column */}
<div>
{/* Cache Stats */}
{stats && <CacheStats stats={stats} />}
{/* Endpoint Stats */}
{stats && <EndpointStats stats={stats} />}
</div>
</div>
{/* Series Data Display */}
{selectedSeriesId && (
<SeriesDataDisplay
seriesId={selectedSeriesId}
observations={seriesObservations}
isLoading={isLoadingObservations}
onClose={handleCloseSeriesData}
/>
)}
</div>
</Layout>
);
};
export default FredPage;

@ -0,0 +1,284 @@
import React, { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
import Layout from '@/components/Layout';
import { TrendingUp, Database, BarChart3, Users, Calendar, DollarSign } from 'lucide-react';
import { stockApi, DatabaseStats, formatNumber } from '@/lib/api';
const Dashboard: React.FC = () => {
const [stats, setStats] = useState<DatabaseStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchStats = async () => {
try {
const data = await stockApi.getDatabaseStats();
setStats(data);
} catch (error) {
console.error('Failed to fetch database stats:', error);
} finally {
setLoading(false);
}
};
fetchStats();
}, []);
const quickStats = [
{
name: '등록된 회사',
value: stats?.companies.total || 0,
icon: Users,
color: 'text-blue-600',
bgColor: 'bg-blue-50',
description: '데이터베이스에 등록된 총 회사 수',
},
{
name: '재무 데이터',
value: stats?.financial_data.total_records || 0,
icon: BarChart3,
color: 'text-green-600',
bgColor: 'bg-green-50',
description: '저장된 재무 데이터 레코드 수',
},
{
name: '주가 데이터',
value: stats?.price_data.total_records || 0,
icon: TrendingUp,
color: 'text-purple-600',
bgColor: 'bg-purple-50',
description: '저장된 주가 데이터 포인트 수',
},
{
name: '계산된 지표',
value: stats?.calculated_metrics.total_records || 0,
icon: Calendar,
color: 'text-orange-600',
bgColor: 'bg-orange-50',
description: '계산된 재무 지표 수',
},
];
return (
<Layout title="Stock Oracle - 대시보드">
<div className="space-y-6">
{/* 환영 섹션 */}
<div className="bg-gradient-to-r from-blue-600 to-purple-600 rounded-lg shadow-sm p-8 text-white">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold mb-2">Stock Oracle에 오신 것을 환영합니다</h1>
<p className="text-blue-100 text-lg">
실시간 주식 데이터와 재무 분석 플랫폼
</p>
<p className="text-blue-200 mt-2">
SEC 실제 데이터를 기반으로 한 정확한 재무 분석을 제공합니다.
</p>
</div>
<div className="hidden md:block">
<TrendingUp className="h-24 w-24 text-blue-200" />
</div>
</div>
</div>
{/* 빠른 통계 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{quickStats.map((stat) => {
const Icon = stat.icon;
return (
<div key={stat.name} className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow">
<div className="flex items-center">
<div className={`flex-shrink-0 p-3 rounded-lg ${stat.bgColor}`}>
<Icon className={`h-6 w-6 ${stat.color}`} />
</div>
<div className="ml-4 flex-1">
<p className="text-sm font-medium text-gray-500">{stat.name}</p>
<div className="text-2xl font-bold text-gray-900">
{loading ? (
<span className="inline-block h-6 w-16 bg-gray-200 rounded animate-pulse"></span>
) : (
<span>{formatNumber(stat.value)}</span>
)}
</div>
</div>
</div>
<p className="text-xs text-gray-600 mt-2">{stat.description}</p>
</div>
);
})}
</div>
{/* 데이터 품질 개요 */}
{stats && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 재무 데이터 품질 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<BarChart3 className="h-5 w-5 mr-2 text-green-600" />
재무 데이터 품질
</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600">실제 데이터 비율</span>
<span className="text-sm font-medium text-green-600">
{((stats.financial_data.real_data / stats.financial_data.total_records) * 100).toFixed(1)}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-green-500 h-3 rounded-full transition-all duration-500"
style={{ width: `${(stats.financial_data.real_data / stats.financial_data.total_records) * 100}%` }}
></div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="text-center p-3 bg-green-50 rounded-lg">
<p className="text-2xl font-bold text-green-600">{formatNumber(stats.financial_data.real_data)}</p>
<p className="text-sm text-green-700">실제 데이터</p>
</div>
<div className="text-center p-3 bg-yellow-50 rounded-lg">
<p className="text-2xl font-bold text-yellow-600">{formatNumber(stats.financial_data.estimated_data)}</p>
<p className="text-sm text-yellow-700">추정 데이터</p>
</div>
</div>
</div>
</div>
{/* 커버리지 정보 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<Database className="h-5 w-5 mr-2 text-blue-600" />
데이터 커버리지
</h3>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600">재무 데이터 보유 회사</span>
<span className="text-lg font-semibold text-gray-900">
{stats.companies.with_financial_data} / {stats.companies.total}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600">주가 데이터 보유 회사</span>
<span className="text-lg font-semibold text-gray-900">
{stats.companies.with_price_data} / {stats.companies.total}
</span>
</div>
<div className="mt-4">
<p className="text-sm text-gray-600 mb-2">주가 데이터 보유 종목</p>
<div className="flex flex-wrap gap-1">
{stats.price_data.tickers.slice(0, 8).map((ticker) => (
<span key={ticker} className="inline-flex px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded">
{ticker}
</span>
))}
{stats.price_data.tickers.length > 8 && (
<span className="inline-flex px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded">
+{stats.price_data.tickers.length - 8}개 더
</span>
)}
</div>
</div>
</div>
</div>
</div>
)}
{/* 빠른 액션 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">빠른 액션</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<a
href="/stock"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:border-orange-300 hover:bg-orange-50 transition-colors group"
>
<TrendingUp className="h-8 w-8 text-orange-600 group-hover:text-orange-700" />
<div className="ml-3">
<p className="text-sm font-medium text-gray-900">종목 조회</p>
<p className="text-xs text-gray-500">개별 종목 상세 정보</p>
</div>
</a>
<a
href="/query"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-colors group"
>
<BarChart3 className="h-8 w-8 text-blue-600 group-hover:text-blue-700" />
<div className="ml-3">
<p className="text-sm font-medium text-gray-900">데이터 조회</p>
<p className="text-xs text-gray-500">주식 재무 데이터 검색</p>
</div>
</a>
<a
href="/database"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:border-green-300 hover:bg-green-50 transition-colors group"
>
<Database className="h-8 w-8 text-green-600 group-hover:text-green-700" />
<div className="ml-3">
<p className="text-sm font-medium text-gray-900">DB 상태</p>
<p className="text-xs text-gray-500">데이터베이스 현황 확인</p>
</div>
</a>
<a
href="/fred"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:border-purple-300 hover:bg-purple-50 transition-colors group"
>
<DollarSign className="h-8 w-8 text-purple-600 group-hover:text-purple-700" />
<div className="ml-3">
<p className="text-sm font-medium text-gray-900">FRED 경제데이터</p>
<p className="text-xs text-gray-500">연방준비제도 경제 지표</p>
</div>
</a>
</div>
</div>
{/* 시스템 상태 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">시스템 상태</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<div className="flex items-center">
<div className="h-3 w-3 bg-green-400 rounded-full mr-2"></div>
<span className="text-sm font-medium text-green-800">API 서버</span>
</div>
<span className="text-xs text-green-600">정상</span>
</div>
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<div className="flex items-center">
<div className="h-3 w-3 bg-green-400 rounded-full mr-2"></div>
<span className="text-sm font-medium text-green-800">데이터베이스</span>
</div>
<span className="text-xs text-green-600">연결됨</span>
</div>
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<div className="flex items-center">
<div className="h-3 w-3 bg-green-400 rounded-full mr-2"></div>
<span className="text-sm font-medium text-green-800">외부 API</span>
</div>
<span className="text-xs text-green-600">활성</span>
</div>
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
<div className="flex items-center">
<div className="h-3 w-3 bg-blue-400 rounded-full mr-2"></div>
<span className="text-sm font-medium text-blue-800">FRED API</span>
</div>
<span className="text-xs text-blue-600">연결됨</span>
</div>
</div>
</div>
</div>
</Layout>
);
};
export default Dashboard;

@ -0,0 +1,57 @@
import React, { useEffect, useState } from 'react';
import Head from 'next/head';
import dynamic from 'next/dynamic';
// Completely disable SSR for the entire page
const LogsPageContent = dynamic(
() => import('../components/LogsPageContent'),
{
ssr: false,
loading: () => (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-600 text-lg">로그 페이지 로딩 중...</p>
</div>
</div>
)
}
);
// This component will never be server-side rendered
const LogsPage: React.FC = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<>
<Head>
<title>시스템 로그 - Stock Oracle</title>
<meta name="description" content="API 요청 및 에러 로그" />
</Head>
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-600">초기화 중...</p>
</div>
</div>
</>
);
}
return (
<>
<Head>
<title>시스템 로그 - Stock Oracle</title>
<meta name="description" content="API 요청 및 에러 로그" />
</Head>
<LogsPageContent />
</>
);
};
export default LogsPage;

@ -0,0 +1,26 @@
import React from 'react';
import dynamic from 'next/dynamic';
import Layout from '@/components/Layout';
const StockQuery = dynamic(() => import('@/components/StockQueryFixed'), {
ssr: false,
loading: () => (
<div className="animate-pulse space-y-4">
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
</div>
),
});
const QueryPage: React.FC = () => {
return (
<Layout title="Stock Oracle - 데이터 조회">
<StockQuery />
</Layout>
);
};
export default QueryPage;

@ -0,0 +1,13 @@
import React from 'react';
import Layout from '@/components/Layout';
import RequestLogViewer from '../components/RequestLogViewer';
const RequestsPage: React.FC = () => {
return (
<Layout title="요청 로그 - Stock Oracle">
<RequestLogViewer />
</Layout>
);
};
export default RequestsPage;

@ -0,0 +1,225 @@
import React, { useState } from 'react';
import Layout from '@/components/Layout';
import { Settings, Save, RefreshCw, AlertCircle, CheckCircle } from 'lucide-react';
const SettingsPage: React.FC = () => {
const [apiUrl, setApiUrl] = useState(process.env.NEXT_PUBLIC_API_URL || 'http://localhost:18001/api/v1');
const [autoRefresh, setAutoRefresh] = useState(true);
const [refreshInterval, setRefreshInterval] = useState(30);
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState(true);
const [savedMessage, setSavedMessage] = useState('');
const handleSave = () => {
// 여기서 실제로는 localStorage나 서버에 설정을 저장
localStorage.setItem('stockOracleSettings', JSON.stringify({
apiUrl,
autoRefresh,
refreshInterval,
theme,
notifications,
}));
setSavedMessage('설정이 저장되었습니다.');
setTimeout(() => setSavedMessage(''), 3000);
};
const handleReset = () => {
setApiUrl('http://localhost:18001/api/v1');
setAutoRefresh(true);
setRefreshInterval(30);
setTheme('light');
setNotifications(true);
};
return (
<Layout title="Stock Oracle - 설정">
<div className="space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900 flex items-center">
<Settings className="h-6 w-6 mr-2 text-blue-600" />
시스템 설정
</h2>
</div>
{/* 성공 메시지 */}
{savedMessage && (
<div className="bg-green-50 border border-green-200 rounded-md p-4">
<div className="flex items-center">
<CheckCircle className="h-5 w-5 text-green-400 mr-2" />
<p className="text-green-800">{savedMessage}</p>
</div>
</div>
)}
{/* API 설정 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">API 설정</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
API URL
</label>
<input
type="url"
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="http://localhost:18001/api/v1"
/>
<p className="text-xs text-gray-500 mt-1">
Stock Oracle API 서버의 주소를 입력하세요.
</p>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="autoRefresh"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="autoRefresh" className="ml-2 block text-sm text-gray-700">
자동 새로고침 활성화
</label>
</div>
{autoRefresh && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
새로고침 간격 (초)
</label>
<select
value={refreshInterval}
onChange={(e) => setRefreshInterval(Number(e.target.value))}
className="w-32 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value={15}>15초</option>
<option value={30}>30초</option>
<option value={60}>1분</option>
<option value={300}>5분</option>
</select>
</div>
)}
</div>
</div>
{/* 사용자 인터페이스 설정 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">사용자 인터페이스</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
테마
</label>
<select
value={theme}
onChange={(e) => setTheme(e.target.value)}
className="w-32 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="light">라이트</option>
<option value="dark">다크</option>
<option value="auto">시스템 설정</option>
</select>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="notifications"
checked={notifications}
onChange={(e) => setNotifications(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="notifications" className="ml-2 block text-sm text-gray-700">
알림 활성화
</label>
</div>
</div>
</div>
{/* 데이터 설정 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">데이터 설정</h3>
<div className="space-y-4">
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div className="flex items-start">
<AlertCircle className="h-5 w-5 text-yellow-400 mr-2 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-yellow-800">데이터 새로고침 주의사항</h4>
<p className="text-sm text-yellow-700 mt-1">
강제 새로고침은 외부 API 호출을 발생시켜 응답 시간이 길어질 수 있습니다.
일반적으로 캐시된 데이터를 사용하는 것을 권장합니다.
</p>
</div>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
<div className="flex items-start">
<AlertCircle className="h-5 w-5 text-blue-400 mr-2 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-blue-800">실제 vs 추정 데이터</h4>
<p className="text-sm text-blue-700 mt-1">
실제 데이터는 SEC EDGAR에서 가져온 정확한 재무 데이터입니다.
추정 데이터는 기존 시스템에서 생성된 임시 데이터입니다.
</p>
</div>
</div>
</div>
</div>
</div>
{/* 시스템 정보 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">시스템 정보</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-500">버전</p>
<p className="text-lg font-medium text-gray-900">Stock Oracle v1.0.0</p>
</div>
<div>
<p className="text-sm text-gray-500">빌드 날짜</p>
<p className="text-lg font-medium text-gray-900">{new Date().toLocaleDateString('ko-KR')}</p>
</div>
<div>
<p className="text-sm text-gray-500">현재 API URL</p>
<p className="text-lg font-medium text-gray-900 break-all">{apiUrl}</p>
</div>
<div>
<p className="text-sm text-gray-500">브라우저 지원</p>
<p className="text-lg font-medium text-gray-900">Chrome, Firefox, Safari, Edge</p>
</div>
</div>
</div>
{/* 액션 버튼 */}
<div className="flex items-center justify-between">
<button
onClick={handleReset}
className="flex items-center px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
>
<RefreshCw className="h-4 w-4 mr-2" />
기본값으로 재설정
</button>
<button
onClick={handleSave}
className="flex items-center px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<Save className="h-4 w-4 mr-2" />
설정 저장
</button>
</div>
</div>
</Layout>
);
};
export default SettingsPage;

@ -0,0 +1,797 @@
import React, { useState, useEffect } from 'react';
import Layout from '@/components/Layout';
import NewsSocialDisplay from '@/components/NewsSocialDisplay';
import {
Search,
TrendingUp,
BarChart3,
Building,
Calendar,
DollarSign,
Percent,
Users,
AlertCircle,
RefreshCw,
ChevronDown,
ChevronUp,
TrendingDown,
Activity,
PieChart,
LineChart,
Eye,
BarChart,
Target,
Layers,
Shield,
Award,
Briefcase,
Calculator
} from 'lucide-react';
import {
stockApi,
Company,
FinancialData,
PriceData,
formatCurrency,
formatNumber,
formatPercentage,
formatDate
} from '@/lib/api';
import dynamic from 'next/dynamic';
const Plot = dynamic(() => import('react-plotly.js'), { ssr: false });
interface StockDetailPageProps {}
type ChartPeriod = '1D' | '5D' | '1M' | '3M' | '6M' | '1Y' | '2Y' | '5Y' | '10Y';
const StockDetailPage: React.FC<StockDetailPageProps> = () => {
const [ticker, setTicker] = useState<string>('');
const [searchTicker, setSearchTicker] = useState<string>('');
const [company, setCompany] = useState<Company | null>(null);
const [financialData, setFinancialData] = useState<FinancialData[]>([]);
const [priceData, setPriceData] = useState<PriceData[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const [showAllFinancialData, setShowAllFinancialData] = useState<boolean>(false);
const [periodType, setPeriodType] = useState<'quarterly' | 'annual' | 'all'>('all');
const [chartPeriod, setChartPeriod] = useState<ChartPeriod>('6M');
const [chartType, setChartType] = useState<'line' | 'candlestick'>('line');
const searchStock = async () => {
if (!searchTicker.trim()) {
setError('종목 코드를 입력해주세요');
return;
}
setLoading(true);
setError('');
setTicker(searchTicker.toUpperCase());
try {
// Get date range for price data based on chart selection
const getDateRangeForChart = (period: ChartPeriod): { start_date: string; end_date: string } => {
const today = new Date();
const endDate = today.toISOString().split('T')[0];
let startDate: Date;
switch (period) {
case '1D':
startDate = new Date(today.getTime() - 1 * 24 * 60 * 60 * 1000);
break;
case '5D':
startDate = new Date(today.getTime() - 5 * 24 * 60 * 60 * 1000);
break;
case '1M':
startDate = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
case '3M':
startDate = new Date(today.getTime() - 90 * 24 * 60 * 60 * 1000);
break;
case '6M':
startDate = new Date(today.getTime() - 180 * 24 * 60 * 60 * 1000);
break;
case '1Y':
startDate = new Date(today.getTime() - 365 * 24 * 60 * 60 * 1000);
break;
case '2Y':
startDate = new Date(today.getTime() - 2 * 365 * 24 * 60 * 60 * 1000);
break;
case '5Y':
startDate = new Date(today.getTime() - 5 * 365 * 24 * 60 * 60 * 1000);
break;
case '10Y':
startDate = new Date(today.getTime() - 10 * 365 * 24 * 60 * 60 * 1000);
break;
default:
startDate = new Date(today.getTime() - 180 * 24 * 60 * 60 * 1000); // Default to 6M
}
return {
start_date: startDate.toISOString().split('T')[0],
end_date: endDate
};
};
const { start_date, end_date } = getDateRangeForChart(chartPeriod);
// 재무 데이터와 주가 데이터를 동시에 가져오기
const [financialResponse, priceResponse] = await Promise.allSettled([
stockApi.getFinancialData({
ticker: searchTicker.toUpperCase(),
start_date: '2020-01-01',
end_date: new Date().toISOString().split('T')[0],
period_type: periodType,
include_metrics: true
}),
stockApi.getPriceData({
ticker: searchTicker.toUpperCase(),
start_date,
end_date,
interval: '1d'
})
]);
// 재무 데이터 처리
if (financialResponse.status === 'fulfilled') {
console.log('🔍 Debug: Financial data loaded', {
company: financialResponse.value.company,
dataCount: financialResponse.value.financial_data?.length,
firstRecord: financialResponse.value.financial_data?.[0],
lastRecord: financialResponse.value.financial_data?.[financialResponse.value.financial_data.length - 1]
});
setCompany(financialResponse.value.company);
setFinancialData(financialResponse.value.financial_data);
}
// 주가 데이터 처리
if (priceResponse.status === 'fulfilled') {
console.log('🔍 Debug: Price data loaded', {
ticker: priceResponse.value.ticker,
dataCount: priceResponse.value.data?.length,
firstRecord: priceResponse.value.data?.[0],
lastRecord: priceResponse.value.data?.[priceResponse.value.data?.length - 1]
});
setPriceData(priceResponse.value.data || []);
}
// 둘 다 실패한 경우에만 에러 표시
if (financialResponse.status === 'rejected' && priceResponse.status === 'rejected') {
setError(`${searchTicker} 종목에 대한 데이터를 찾을 수 없습니다.`);
}
} catch (err: any) {
setError(err.message || '데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
searchStock();
}
};
// 차트 기간이 변경될 때 데이터 다시 로드
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only re-fetch when chartPeriod changes
useEffect(() => {
if (ticker) {
searchStock();
}
}, [chartPeriod]);
// Helper function to format chart data
const formatChartData = () => {
if (!priceData || priceData.length === 0) return [];
// Sort by date ascending (oldest to newest) for proper chronological display
const sortedData = [...priceData].sort((a, b) =>
new Date(a.date).getTime() - new Date(b.date).getTime()
);
return sortedData.map(item => ({
date: formatDate(item.date, 'MM/dd'),
price: item.close,
volume: item.volume || 0,
high: item.high,
low: item.low,
open: item.open,
}));
};
// Calculate price change from previous to latest price
const getPriceChange = () => {
if (!priceData || priceData.length < 2) return { change: 0, changePercent: 0 };
// API returns data in ascending order, so latest is last, previous is second to last
const latest = priceData[priceData.length - 1];
const previous = priceData[priceData.length - 2];
const change = latest.close - previous.close;
const changePercent = (change / previous.close) * 100;
return { change, changePercent };
};
// Calculate 52-week range
const get52WeekRange = () => {
if (!priceData || priceData.length === 0) return { low: 0, high: 0 };
const prices = priceData.map(p => p.close);
return {
low: Math.min(...prices),
high: Math.max(...prices)
};
};
// Display financial data with most recent first
const displayedFinancialData = showAllFinancialData
? [...financialData].reverse()
: [...(financialData || [])].reverse().slice(0, 8);
const getLatestPrice = () => {
if (!priceData || priceData.length === 0) {
console.log('🔍 Debug: No price data available', { priceData });
return null;
}
// API returns data in ascending order (oldest first), so get the last element for most recent
const latest = priceData[priceData.length - 1];
console.log('🔍 Debug: Latest price data', { latest, totalRecords: priceData.length });
return latest;
};
const getLatestFinancials = () => {
if (!financialData || financialData.length === 0) {
console.log('🔍 Debug: No financial data available', { financialData });
return null;
}
// Get the most recent financial data (last element if sorted ascending)
const latest = financialData[financialData.length - 1];
console.log('🔍 Debug: Latest financial data', { latest, totalRecords: financialData.length });
return latest;
};
const latestPrice = getLatestPrice();
const latestFinancials = getLatestFinancials();
const chartData = formatChartData();
const priceChange = getPriceChange();
const weekRange = get52WeekRange();
const chartPeriods: ChartPeriod[] = ['1D', '5D', '1M', '3M', '6M', '1Y', '2Y', '5Y', '10Y'];
return (
<Layout title="Stock Analysis - Stock Oracle">
<div className="space-y-6">
{/* 검색 섹션 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-4 flex items-center">
<Search className="h-6 w-6 mr-2 text-blue-600" />
종목 상세 조회
</h1>
<div className="flex gap-4">
<div className="flex-1">
<label htmlFor="ticker" className="block text-sm font-medium text-gray-700 mb-2">
종목 코드 (예: AAPL, TSLA, MSFT)
</label>
<input
type="text"
id="ticker"
value={searchTicker}
onChange={(e) => setSearchTicker(e.target.value.toUpperCase())}
onKeyPress={handleKeyPress}
placeholder="종목 코드를 입력하세요"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
기간 유형
</label>
<select
value={periodType}
onChange={(e) => setPeriodType(e.target.value as 'quarterly' | 'annual' | 'all')}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="all">전체</option>
<option value="quarterly">분기별</option>
<option value="annual">연간</option>
</select>
</div>
<div className="flex items-end">
<button
onClick={searchStock}
disabled={loading}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{loading ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
조회
</button>
</div>
</div>
{error && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-red-500" />
<span className="text-red-700">{error}</span>
</div>
)}
</div>
{/* Professional Stock Analysis Dashboard */}
{company && (
<div className="space-y-6">
{/* Premium Company Header */}
<div className="bg-gradient-to-r from-slate-900 to-slate-800 rounded-xl shadow-xl text-white p-8">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-white/10 rounded-lg">
<Building className="h-8 w-8 text-white" />
</div>
<div>
<h1 className="text-4xl font-bold">{company.name}</h1>
<div className="flex items-center gap-3 mt-1">
<span className="text-2xl font-mono text-blue-300">{company.ticker}</span>
{company.sector && (
<span className="text-slate-300 text-lg">• {company.sector}</span>
)}
</div>
</div>
</div>
{/* Live Price Display */}
{latestPrice && (
<div className="flex items-center gap-6 mt-4">
<div className="text-5xl font-bold font-mono">
{formatCurrency(latestPrice.close)}
</div>
<div className={`flex items-center gap-2 px-4 py-2 rounded-lg text-lg font-semibold ${
priceChange.change >= 0
? 'bg-green-500/20 text-green-300'
: 'bg-red-500/20 text-red-300'
}`}>
{priceChange.change >= 0 ? <TrendingUp className="h-5 w-5" /> : <TrendingDown className="h-5 w-5" />}
<span>
{priceChange.change >= 0 ? '+' : ''}{priceChange.change.toFixed(2)}
({priceChange.changePercent >= 0 ? '+' : ''}{priceChange.changePercent.toFixed(2)}%)
</span>
</div>
</div>
)}
</div>
{/* Market Status */}
<div className="text-right">
<div className="flex items-center gap-2 mb-2">
<div className="h-3 w-3 bg-green-400 rounded-full animate-pulse"></div>
<span className="text-green-300 font-medium">Live Data</span>
</div>
{latestPrice && (
<div className="text-slate-300 text-sm">
Last Updated: {formatDate(latestPrice.date)}
</div>
)}
</div>
</div>
</div>
{/* Key Performance Indicators Grid */}
{(latestPrice || latestFinancials) && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{latestPrice && (
<>
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-2">
<Target className="h-5 w-5 text-indigo-500" />
<span className="text-xs text-gray-500 uppercase tracking-wide">52W High</span>
</div>
<div className="text-xl font-bold text-gray-900">{formatCurrency(weekRange.high)}</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-2">
<TrendingDown className="h-5 w-5 text-red-500" />
<span className="text-xs text-gray-500 uppercase tracking-wide">52W Low</span>
</div>
<div className="text-xl font-bold text-gray-900">{formatCurrency(weekRange.low)}</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-2">
<Activity className="h-5 w-5 text-purple-500" />
<span className="text-xs text-gray-500 uppercase tracking-wide">Volume</span>
</div>
<div className="text-xl font-bold text-gray-900">{formatNumber(latestPrice.volume)}</div>
</div>
</>
)}
{latestFinancials && (
<>
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-2">
<Calculator className="h-5 w-5 text-blue-500" />
<span className="text-xs text-gray-500 uppercase tracking-wide">P/E Ratio</span>
</div>
<div className="text-xl font-bold text-gray-900">
{latestFinancials.eps && latestPrice ?
(latestPrice.close / latestFinancials.eps).toFixed(2) : 'N/A'
}
</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-2">
<Award className="h-5 w-5 text-green-500" />
<span className="text-xs text-gray-500 uppercase tracking-wide">ROE</span>
</div>
<div className="text-xl font-bold text-gray-900">{formatPercentage(latestFinancials.roe)}</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-2">
<Percent className="h-5 w-5 text-orange-500" />
<span className="text-xs text-gray-500 uppercase tracking-wide">Margin</span>
</div>
<div className="text-xl font-bold text-gray-900">{formatPercentage(latestFinancials.net_margin)}</div>
</div>
</>
)}
</div>
)}
{/* Main Dashboard Grid */}
<div className="grid lg:grid-cols-3 gap-6">
{/* Chart Section - Takes 2/3 width */}
<div className="lg:col-span-2 space-y-6">
{/* Stock Chart */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-50 rounded-lg">
<BarChart className="h-6 w-6 text-blue-600" />
</div>
<h2 className="text-xl font-bold text-gray-900">주가 차트</h2>
</div>
<div className="flex items-center gap-4">
{/* Period Selector */}
<div className="flex bg-gray-50 rounded-lg p-1">
{chartPeriods.map((period) => (
<button
key={period}
onClick={() => setChartPeriod(period)}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-all ${
chartPeriod === period
? 'bg-white text-blue-600 shadow-sm ring-1 ring-blue-200'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{period}
</button>
))}
</div>
</div>
</div>
{/* Chart Display */}
<div className="bg-gray-50 rounded-xl p-4 border">
<Plot
data={[
{
x: chartData.map((d:any) => d.date),
y: chartData.map((d:any) => d.price),
type: 'scatter',
mode: 'lines',
name: '주가',
line: { color: '#10b981', width: 3 },
yaxis: 'y1',
},
{
x: chartData.map((d:any) => d.date),
y: chartData.map((d:any) => d.volume),
type: 'bar',
name: '거래량',
marker: { color: 'rgba(59,130,246,0.4)' },
yaxis: 'y2',
},
]}
layout={{
autosize: true,
height: 400,
margin: { t: 20, r: 20, b: 40, l: 40 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
xaxis: { title: '날짜', tickfont: { size: 12 } },
yaxis: { title: '주가', titlefont: { size: 12 }, tickprefix: '$' },
yaxis2: {
title: '거래량',
overlaying: 'y',
side: 'right',
showgrid: false,
},
legend: { orientation: 'h', y: -0.2 },
}}
style={{ width: '100%', height: '100%' }}
config={{ displayModeBar: false, responsive: true }}
/>
</div>
</div>
{/* Financial Trend Chart */}
{financialData.length > 1 && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-green-50 rounded-lg">
<TrendingUp className="h-6 w-6 text-green-600" />
</div>
<h2 className="text-xl font-bold text-gray-900">재무 트렌드</h2>
</div>
<div className="bg-gray-50 rounded-xl p-4 border">
<Plot
data={(() => {
const sorted = [...financialData]
.slice(0, 8)
.sort((a, b) => new Date(a.period_date).getTime() - new Date(b.period_date).getTime());
const x = sorted.map((d) => formatDate(d.period_date, 'MM/yy'));
return [
{
x,
y: sorted.map((d) => d.revenue ?? null),
type: 'scatter',
mode: 'lines+markers',
name: '매출액',
line: { color: '#3b82f6', width: 3 },
marker: { color: '#3b82f6' },
},
{
x,
y: sorted.map((d) => d.net_income ?? null),
type: 'scatter',
mode: 'lines+markers',
name: '순이익',
line: { color: '#10b981', width: 3 },
marker: { color: '#10b981' },
},
];
})()}
layout={{
autosize: true,
height: 300,
margin: { t: 10, r: 20, b: 40, l: 50 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
xaxis: { title: '기간', tickfont: { size: 12 } },
yaxis: { title: '금액', tickprefix: '$' },
legend: { orientation: 'h', y: -0.2 },
}}
style={{ width: '100%', height: '100%' }}
config={{ displayModeBar: false, responsive: true }}
/>
</div>
</div>
)}
</div>
{/* Sidebar - Takes 1/3 width */}
<div className="space-y-6">
{/* Company Overview */}
{company.business_description && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-purple-50 rounded-lg">
<Briefcase className="h-5 w-5 text-purple-600" />
</div>
<h3 className="text-lg font-bold text-gray-900">회사 개요</h3>
</div>
<p className="text-gray-700 leading-relaxed text-sm">{company.business_description}</p>
</div>
)}
{/* Key Financial Metrics */}
{latestFinancials && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-blue-50 rounded-lg">
<BarChart3 className="h-5 w-5 text-blue-600" />
</div>
<h3 className="text-lg font-bold text-gray-900">주요 재무 지표</h3>
</div>
<div className="space-y-4">
<div className="flex justify-between items-center p-3 bg-blue-50 rounded-lg">
<span className="text-blue-700 font-medium">매출액</span>
<span className="font-bold text-blue-900">{formatCurrency(latestFinancials.revenue)}</span>
</div>
<div className="flex justify-between items-center p-3 bg-green-50 rounded-lg">
<span className="text-green-700 font-medium">순이익</span>
<span className="font-bold text-green-900">{formatCurrency(latestFinancials.net_income)}</span>
</div>
<div className="flex justify-between items-center p-3 bg-purple-50 rounded-lg">
<span className="text-purple-700 font-medium">총 자산</span>
<span className="font-bold text-purple-900">{formatCurrency(latestFinancials.total_assets)}</span>
</div>
<div className="flex justify-between items-center p-3 bg-orange-50 rounded-lg">
<span className="text-orange-700 font-medium">EPS</span>
<span className="font-bold text-orange-900">
{latestFinancials.eps ? `$${latestFinancials.eps.toFixed(2)}` : 'N/A'}
</span>
</div>
</div>
</div>
)}
{/* Price Statistics */}
{latestPrice && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-indigo-50 rounded-lg">
<DollarSign className="h-5 w-5 text-indigo-600" />
</div>
<h3 className="text-lg font-bold text-gray-900">주가 정보</h3>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">시가</span>
<span className="font-semibold">{formatCurrency(latestPrice.open)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">고가</span>
<span className="font-semibold">{formatCurrency(latestPrice.high)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">저가</span>
<span className="font-semibold">{formatCurrency(latestPrice.low)}</span>
</div>
<div className="flex justify-between border-t pt-3">
<span className="text-gray-600">거래량</span>
<span className="font-semibold">{formatNumber(latestPrice.volume)}</span>
</div>
</div>
</div>
)}
{/* Financial Ratios */}
{latestFinancials && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-emerald-50 rounded-lg">
<Calculator className="h-5 w-5 text-emerald-600" />
</div>
<h3 className="text-lg font-bold text-gray-900">재무 비율</h3>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">ROA</span>
<span className="font-semibold">{formatPercentage(latestFinancials.roa)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">순이익률</span>
<span className="font-semibold">{formatPercentage(latestFinancials.net_margin)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">부채비율</span>
<span className="font-semibold">
{latestFinancials.total_debt && latestFinancials.total_assets ?
formatPercentage((latestFinancials.total_debt / latestFinancials.total_assets) * 100) : 'N/A'
}
</span>
</div>
</div>
</div>
)}
</div>
</div>
{/* Financial Data Table */}
{financialData && financialData.length > 0 && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-50 rounded-lg">
<Layers className="h-6 w-6 text-slate-600" />
</div>
<h2 className="text-xl font-bold text-gray-900">재무 데이터 히스토리</h2>
<span className="bg-gray-100 text-gray-600 px-3 py-1 rounded-full text-sm font-medium">
{financialData.length}개 기록
</span>
</div>
{financialData.length > 8 && (
<button
onClick={() => setShowAllFinancialData(!showAllFinancialData)}
className="flex items-center gap-2 text-blue-600 hover:text-blue-800 font-medium"
>
{showAllFinancialData ? (
<>접기 <ChevronUp className="h-4 w-4" /></>
) : (
<>모두 보기 <ChevronDown className="h-4 w-4" /></>
)}
</button>
)}
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">날짜</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">유형</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">매출액</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">순이익</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">EPS</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">ROE</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">순이익률</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{displayedFinancialData.map((data, index) => (
<tr key={index} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{formatDate(data.period_date)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-3 py-1 text-xs font-semibold rounded-full ${
data.period_type === 'quarterly'
? 'bg-blue-100 text-blue-800 border border-blue-200'
: 'bg-green-100 text-green-800 border border-green-200'
}`}>
{data.period_type === 'quarterly' ? '분기' : '연간'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">
{formatCurrency(data.revenue)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">
{formatCurrency(data.net_income)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">
{data.eps ? `$${data.eps.toFixed(2)}` : 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">
{formatPercentage(data.roe)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">
{formatPercentage(data.net_margin)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
{/* 뉴스 및 소셜 미디어 섹션 */}
{ticker && (
<NewsSocialDisplay ticker={ticker} />
)}
{/* 데이터 없음 메시지 */}
{ticker && !loading && !company && (!financialData || financialData.length === 0) && (!priceData || priceData.length === 0) && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 text-center">
<AlertCircle className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">데이터를 찾을 수 없습니다</h3>
<p className="text-gray-500">
"{ticker}" 종목에 대한 저장된 데이터가 없습니다. 다른 종목 코드를 시도해보세요.
</p>
</div>
)}
</div>
</Layout>
);
};
export default StockDetailPage;

@ -0,0 +1,125 @@
import React, { useState } from 'react';
import Layout from '../components/Layout';
import { fredApi } from '../lib/api';
const TestFredPage: React.FC = () => {
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const testUsageStats = async () => {
setLoading(true);
setError(null);
try {
const stats = await fredApi.getUsageStats();
setResult({ type: 'Usage Stats', data: stats });
} catch (err: any) {
setError(`Error: ${err.message}`);
console.error('Test error:', err);
} finally {
setLoading(false);
}
};
const testSingleSeries = async () => {
setLoading(true);
setError(null);
try {
const response = await fredApi.proxyRequest({
endpoint: 'series',
params: { series_id: 'GDP' }
});
setResult({ type: 'Single Series (GDP)', data: response });
} catch (err: any) {
setError(`Error: ${err.message}`);
console.error('Test error:', err);
} finally {
setLoading(false);
}
};
const testPopularSeries = async () => {
setLoading(true);
setError(null);
try {
const series = await fredApi.getPopularSeries();
setResult({ type: 'Popular Series', data: series });
} catch (err: any) {
setError(`Error: ${err.message}`);
console.error('Test error:', err);
} finally {
setLoading(false);
}
};
return (
<Layout>
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">🧪 FRED API Test</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<button
onClick={testUsageStats}
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
Test Usage Stats
</button>
<button
onClick={testSingleSeries}
disabled={loading}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
>
Test Single Series
</button>
<button
onClick={testPopularSeries}
disabled={loading}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
Test Popular Series
</button>
</div>
{loading && (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-2 text-gray-600">Testing API...</p>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h3 className="text-red-800 font-medium">Error</h3>
<p className="text-red-600 text-sm mt-1">{error}</p>
</div>
)}
{result && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{result.type} - Result
</h3>
<pre className="bg-gray-100 p-4 rounded-lg overflow-auto text-xs">
{JSON.stringify(result.data, null, 2)}
</pre>
</div>
)}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h3 className="text-yellow-800 font-medium">Debug Info</h3>
<p className="text-yellow-700 text-sm mt-1">
API Base URL: {process.env.NEXT_PUBLIC_API_URL || 'http://localhost:18001/api/v1'}
</p>
<p className="text-yellow-700 text-sm">
Open browser dev tools to see network requests and console logs
</p>
</div>
</div>
</Layout>
);
};
export default TestFredPage;

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

@ -0,0 +1 @@
# This file exists to ensure the public directory is tracked by git

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 877 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 877 B

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Crystal Ball Base -->
<ellipse cx="256" cy="420" rx="120" ry="40" fill="#1e40af"/>
<!-- Crystal Ball -->
<circle cx="256" cy="256" r="180" fill="#3b82f6" opacity="0.9"/>
<!-- Crystal Ball Shine -->
<ellipse cx="220" cy="200" rx="60" ry="80" fill="#60a5fa" opacity="0.6"/>
<!-- Stock Chart Line -->
<path d="M 150 280 L 180 260 L 210 270 L 240 240 L 270 250 L 300 220 L 330 230 L 360 200"
stroke="white" stroke-width="6" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Chart Points -->
<circle cx="150" cy="280" r="8" fill="white"/>
<circle cx="180" cy="260" r="8" fill="white"/>
<circle cx="210" cy="270" r="8" fill="white"/>
<circle cx="240" cy="240" r="8" fill="white"/>
<circle cx="270" cy="250" r="8" fill="white"/>
<circle cx="300" cy="220" r="8" fill="white"/>
<circle cx="330" cy="230" r="8" fill="white"/>
<circle cx="360" cy="200" r="8" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1008 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@ -0,0 +1,26 @@
{
"name": "Stock Oracle",
"short_name": "StockOracle",
"description": "Investment Data Analysis Platform using SEC filings",
"start_url": "/",
"display": "standalone",
"background_color": "#f3f4f6",
"theme_color": "#3b82f6",
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml"
},
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

@ -0,0 +1,75 @@
const sharp = require('sharp');
const fs = require('fs');
const path = require('path');
// SVG content for the favicon
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Crystal Ball Base -->
<ellipse cx="256" cy="420" rx="120" ry="40" fill="#1e40af"/>
<!-- Crystal Ball -->
<circle cx="256" cy="256" r="180" fill="#3b82f6" opacity="0.9"/>
<!-- Crystal Ball Shine -->
<ellipse cx="220" cy="200" rx="60" ry="80" fill="#60a5fa" opacity="0.6"/>
<!-- Stock Chart Line -->
<path d="M 150 280 L 180 260 L 210 270 L 240 240 L 270 250 L 300 220 L 330 230 L 360 200"
stroke="white" stroke-width="6" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Chart Points -->
<circle cx="150" cy="280" r="8" fill="white"/>
<circle cx="180" cy="260" r="8" fill="white"/>
<circle cx="210" cy="270" r="8" fill="white"/>
<circle cx="240" cy="240" r="8" fill="white"/>
<circle cx="270" cy="250" r="8" fill="white"/>
<circle cx="300" cy="220" r="8" fill="white"/>
<circle cx="330" cy="230" r="8" fill="white"/>
<circle cx="360" cy="200" r="8" fill="white"/>
</svg>`;
const publicDir = path.join(__dirname, '..', 'public');
// Ensure public directory exists
if (!fs.existsSync(publicDir)) {
fs.mkdirSync(publicDir, { recursive: true });
}
async function generateIcons() {
try {
// Save SVG file
fs.writeFileSync(path.join(publicDir, 'favicon.svg'), svgContent);
console.log('✅ Generated favicon.svg');
// Generate PNG icons in various sizes
const sizes = [
{ size: 16, name: 'favicon-16x16.png' },
{ size: 32, name: 'favicon-32x32.png' },
{ size: 192, name: 'icon-192.png' },
{ size: 512, name: 'icon-512.png' },
{ size: 180, name: 'apple-touch-icon.png' }
];
for (const { size, name } of sizes) {
await sharp(Buffer.from(svgContent))
.resize(size, size)
.png()
.toFile(path.join(publicDir, name));
console.log(`✅ Generated ${name}`);
}
// Generate favicon.ico (multi-resolution)
await sharp(Buffer.from(svgContent))
.resize(32, 32)
.png()
.toFile(path.join(publicDir, 'favicon.ico'));
console.log('✅ Generated favicon.ico');
console.log('\n🎉 All icons generated successfully!');
} catch (error) {
console.error('Error generating icons:', error);
process.exit(1);
}
}
generateIcons();

@ -0,0 +1,198 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
font-family: 'Inter', sans-serif;
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
a {
color: inherit;
text-decoration: none;
}
/* 커스텀 스크롤바 */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 애니메이션 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.5s ease-out;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* 테이블 스타일 */
.table-container {
overflow-x: auto;
border-radius: 8px;
border: 1px solid #e5e7eb;
}
.table-container table {
width: 100%;
border-collapse: collapse;
}
.table-container th,
.table-container td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.table-container th {
background-color: #f9fafb;
font-weight: 600;
color: #374151;
}
.table-container tr:hover {
background-color: #f9fafb;
}
/* 카드 효과 */
.card {
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-6;
transition: all 0.2s ease-in-out;
}
.card:hover {
@apply shadow-md;
transform: translateY(-2px);
}
/* 상태 배지 */
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-success {
@apply bg-green-100 text-green-800;
}
.badge-danger {
@apply bg-red-100 text-red-800;
}
.badge-warning {
@apply bg-yellow-100 text-yellow-800;
}
.badge-info {
@apply bg-blue-100 text-blue-800;
}
/* 로딩 스피너 */
.spinner {
border: 2px solid #f3f3f3;
border-top: 2px solid #3498db;
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Input field text visibility fix */
input[type="text"],
input[type="date"],
input[type="email"],
input[type="password"],
input[type="number"],
select,
textarea {
color: #1f2937 !important; /* Force dark gray text */
background-color: #ffffff !important; /* Force white background */
}
input[type="text"]:focus,
input[type="date"]:focus,
input[type="email"]:focus,
input[type="password"]:focus,
input[type="number"]:focus,
select:focus,
textarea:focus {
color: #1f2937 !important;
background-color: #ffffff !important;
}
input::placeholder {
color: #6b7280 !important; /* Force gray placeholder text */
opacity: 1 !important;
}

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

Loading…
Cancel
Save