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
@ -0,0 +1,16 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.claude
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
tests/
|
||||
docs/
|
||||
examples/
|
||||
frontend/
|
||||
portainer/
|
||||
*.db
|
||||
*.md
|
||||
!requirements*.txt
|
||||
data/
|
||||
@ -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,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,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"]
|
||||
@ -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;
|
||||
@ -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
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 439 B |
|
After Width: | Height: | Size: 877 B |
|
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 |
|
After Width: | Height: | Size: 5.0 KiB |
|
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;
|
||||
}
|
||||