From 355aeb19d33f98305ffadd891b8313823b87f665 Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Wed, 11 Mar 2026 23:58:11 -0700 Subject: [PATCH] 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 --- .dockerignore | 16 + .gitignore | 127 + API_DOCUMENTATION.md | 463 + CHANGELOG.md | 57 + Dockerfile | 27 + README.md | 894 ++ app/__init__.py | 1 + app/api/__init__.py | 1 + app/api/v1/api.py | 23 + app/api/v1/endpoints/__init__.py | 3 + app/api/v1/endpoints/catalog.py | 406 + app/api/v1/endpoints/database.py | 473 + app/api/v1/endpoints/error_logs.py | 424 + app/api/v1/endpoints/etf.py | 95 + app/api/v1/endpoints/financial.py | 662 ++ app/api/v1/endpoints/fred.py | 351 + app/api/v1/endpoints/health.py | 60 + app/api/v1/endpoints/migration.py | 207 + app/api/v1/endpoints/news.py | 278 + app/api/v1/endpoints/price.py | 560 + app/api/v1/endpoints/request_logs.py | 354 + app/api/v1/endpoints/stocks.py | 505 + app/core/config.py | 127 + app/core/database.py | 53 + app/main.py | 285 + app/middleware/error_logger.py | 324 + app/models/__init__.py | 15 + app/models/error_log.py | 72 + app/models/etf.py | 94 + app/models/financial.py | 175 + app/models/fred_data.py | 108 + app/models/request_log.py | 59 + app/schemas/__init__.py | 33 + app/schemas/error_log.py | 80 + app/schemas/financial.py | 507 + app/schemas/request_log.py | 50 + app/schemas/validators.py | 54 + app/services/__init__.py | 3 + app/services/etf_holdings_fetcher.py | 1927 ++++ app/services/etf_loader_service.py | 126 + app/services/financial_service.py | 626 ++ app/services/fred_proxy_service.py | 788 ++ app/services/fred_service.py | 441 + app/services/news_social_service.py | 542 + app/services/price_data_service.py | 942 ++ app/services/real_sec_financial_service.py | 339 + app/services/sec_data_service.py | 41 + app/services/sec_edgar_service.py | 376 + app/services/yahoo_52week_gainers_service.py | 304 + app/services/yahoo_most_active_service.py | 237 + app/utils/__init__.py | 3 + app/utils/cache.py | 138 + app/utils/date_utils.py | 329 + docker-compose.yml | 90 + docs/ETF_API.md | 124 + docs/PYTHON_CLIENT.md | 538 + examples/client_usage.py | 287 + frontend/.dockerignore | 14 + frontend/.env.example | 15 + frontend/Dockerfile | 55 + frontend/README.md | 309 + frontend/components/ClientLayout.tsx | 188 + frontend/components/DatabaseStats.tsx | 247 + frontend/components/ErrorLogViewer.tsx | 572 + frontend/components/Layout.tsx | 16 + frontend/components/LogsPageContent.tsx | 14 + frontend/components/NewsSocialDisplay.tsx | 403 + frontend/components/RequestLogViewer.tsx | 433 + frontend/components/StockQueryFixed.tsx | 262 + frontend/components/UnifiedLogViewer.tsx | 689 ++ frontend/lib/api.ts | 686 ++ frontend/next-env.d.ts | 5 + frontend/next.config.js | 40 + frontend/package-lock.json | 9861 ++++++++++++++++++ frontend/package.json | 44 + frontend/pages/_app.tsx | 21 + frontend/pages/_document.tsx | 15 + frontend/pages/_error.tsx | 37 + frontend/pages/database.tsx | 144 + frontend/pages/errors.tsx | 13 + frontend/pages/fred.tsx | 516 + frontend/pages/index.tsx | 284 + frontend/pages/logs.tsx | 57 + frontend/pages/query.tsx | 26 + frontend/pages/requests.tsx | 13 + frontend/pages/settings.tsx | 225 + frontend/pages/stock.tsx | 797 ++ frontend/pages/test-fred.tsx | 125 + frontend/postcss.config.js | 6 + frontend/public/.gitkeep | 1 + frontend/public/apple-touch-icon.png | Bin 0 -> 5098 bytes frontend/public/favicon-16x16.png | Bin 0 -> 439 bytes frontend/public/favicon-32x32.png | Bin 0 -> 877 bytes frontend/public/favicon.ico | Bin 0 -> 877 bytes frontend/public/favicon.svg | 24 + frontend/public/icon-192.png | Bin 0 -> 5159 bytes frontend/public/icon-512.png | Bin 0 -> 15168 bytes frontend/public/manifest.json | 26 + frontend/scripts/generate-icons.js | 75 + frontend/styles/globals.css | 198 + frontend/tailwind.config.js | 43 + frontend/tsconfig.json | 27 + frontend/types/react-plotly.js.d.ts | 5 + portainer/.env.portainer | 46 + portainer/PORTAINER_DEPLOYMENT_GUIDE.md | 240 + portainer/docker-compose.full-stack.yml | 156 + portainer/docker-compose.portainer.yml | 133 + portainer/generate-ssl.sh | 22 + portainer/install-portainer.sh | 34 + portainer/nginx/nginx-full.conf | 169 + portainer/nginx/nginx.conf | 144 + portainer/quick-deploy.sh | 179 + portainer/stack-template.json | 104 + requirements-api.txt | 42 + requirements-client.txt | 18 + requirements-test.txt | 8 + requirements.txt | 10 + run_tests.sh | 33 + scripts/init_db.sql | 21 + stock_oracle_analyzer.py | 531 + stock_oracle_client.py | 844 ++ tests/README.md | 173 + tests/TEST_SUMMARY.md | 157 + tests/__init__.py | 1 + tests/conftest.py | 116 + tests/run_all_tests.py | 236 + tests/test_15year_financial_data.py | 453 + tests/test_5year_comparison.py | 286 + tests/test_catalog.py | 189 + tests/test_final_verification.py | 300 + tests/test_financial.py | 188 + tests/test_health.py | 45 + tests/test_integration.py | 209 + tests/test_migration.py | 160 + tests/test_mock_data.py | 51 + tests/test_ohlcv.py | 125 + tests/test_quick.py | 170 + tests/test_real_sec_vs_yfinance_plus.py | 225 + tests/test_sec_edgar_15year.py | 258 + tests/test_simple.py | 85 + tests/test_simple_real_data.py | 100 + tests/test_yfinance_comparison.py | 190 + tests/test_yfinance_fallback.py | 75 + yfinance_plus | 1 + 144 files changed, 39282 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 API_DOCUMENTATION.md create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/v1/api.py create mode 100644 app/api/v1/endpoints/__init__.py create mode 100644 app/api/v1/endpoints/catalog.py create mode 100644 app/api/v1/endpoints/database.py create mode 100644 app/api/v1/endpoints/error_logs.py create mode 100644 app/api/v1/endpoints/etf.py create mode 100644 app/api/v1/endpoints/financial.py create mode 100644 app/api/v1/endpoints/fred.py create mode 100644 app/api/v1/endpoints/health.py create mode 100644 app/api/v1/endpoints/migration.py create mode 100644 app/api/v1/endpoints/news.py create mode 100644 app/api/v1/endpoints/price.py create mode 100644 app/api/v1/endpoints/request_logs.py create mode 100644 app/api/v1/endpoints/stocks.py create mode 100644 app/core/config.py create mode 100644 app/core/database.py create mode 100644 app/main.py create mode 100644 app/middleware/error_logger.py create mode 100644 app/models/__init__.py create mode 100644 app/models/error_log.py create mode 100644 app/models/etf.py create mode 100644 app/models/financial.py create mode 100644 app/models/fred_data.py create mode 100644 app/models/request_log.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/error_log.py create mode 100644 app/schemas/financial.py create mode 100644 app/schemas/request_log.py create mode 100644 app/schemas/validators.py create mode 100644 app/services/__init__.py create mode 100644 app/services/etf_holdings_fetcher.py create mode 100644 app/services/etf_loader_service.py create mode 100644 app/services/financial_service.py create mode 100644 app/services/fred_proxy_service.py create mode 100644 app/services/fred_service.py create mode 100644 app/services/news_social_service.py create mode 100644 app/services/price_data_service.py create mode 100644 app/services/real_sec_financial_service.py create mode 100644 app/services/sec_data_service.py create mode 100644 app/services/sec_edgar_service.py create mode 100644 app/services/yahoo_52week_gainers_service.py create mode 100644 app/services/yahoo_most_active_service.py create mode 100644 app/utils/__init__.py create mode 100644 app/utils/cache.py create mode 100644 app/utils/date_utils.py create mode 100644 docker-compose.yml create mode 100644 docs/ETF_API.md create mode 100644 docs/PYTHON_CLIENT.md create mode 100644 examples/client_usage.py create mode 100644 frontend/.dockerignore create mode 100644 frontend/.env.example create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/components/ClientLayout.tsx create mode 100644 frontend/components/DatabaseStats.tsx create mode 100644 frontend/components/ErrorLogViewer.tsx create mode 100644 frontend/components/Layout.tsx create mode 100644 frontend/components/LogsPageContent.tsx create mode 100644 frontend/components/NewsSocialDisplay.tsx create mode 100644 frontend/components/RequestLogViewer.tsx create mode 100644 frontend/components/StockQueryFixed.tsx create mode 100644 frontend/components/UnifiedLogViewer.tsx create mode 100644 frontend/lib/api.ts create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.js create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/pages/_app.tsx create mode 100644 frontend/pages/_document.tsx create mode 100644 frontend/pages/_error.tsx create mode 100644 frontend/pages/database.tsx create mode 100644 frontend/pages/errors.tsx create mode 100644 frontend/pages/fred.tsx create mode 100644 frontend/pages/index.tsx create mode 100644 frontend/pages/logs.tsx create mode 100644 frontend/pages/query.tsx create mode 100644 frontend/pages/requests.tsx create mode 100644 frontend/pages/settings.tsx create mode 100644 frontend/pages/stock.tsx create mode 100644 frontend/pages/test-fred.tsx create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/.gitkeep create mode 100644 frontend/public/apple-touch-icon.png create mode 100644 frontend/public/favicon-16x16.png create mode 100644 frontend/public/favicon-32x32.png create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icon-192.png create mode 100644 frontend/public/icon-512.png create mode 100644 frontend/public/manifest.json create mode 100644 frontend/scripts/generate-icons.js create mode 100644 frontend/styles/globals.css create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/types/react-plotly.js.d.ts create mode 100644 portainer/.env.portainer create mode 100644 portainer/PORTAINER_DEPLOYMENT_GUIDE.md create mode 100644 portainer/docker-compose.full-stack.yml create mode 100644 portainer/docker-compose.portainer.yml create mode 100755 portainer/generate-ssl.sh create mode 100755 portainer/install-portainer.sh create mode 100644 portainer/nginx/nginx-full.conf create mode 100644 portainer/nginx/nginx.conf create mode 100755 portainer/quick-deploy.sh create mode 100644 portainer/stack-template.json create mode 100644 requirements-api.txt create mode 100644 requirements-client.txt create mode 100644 requirements-test.txt create mode 100644 requirements.txt create mode 100755 run_tests.sh create mode 100644 scripts/init_db.sql create mode 100644 stock_oracle_analyzer.py create mode 100644 stock_oracle_client.py create mode 100644 tests/README.md create mode 100644 tests/TEST_SUMMARY.md create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100755 tests/run_all_tests.py create mode 100755 tests/test_15year_financial_data.py create mode 100644 tests/test_5year_comparison.py create mode 100644 tests/test_catalog.py create mode 100644 tests/test_final_verification.py create mode 100644 tests/test_financial.py create mode 100644 tests/test_health.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_migration.py create mode 100644 tests/test_mock_data.py create mode 100644 tests/test_ohlcv.py create mode 100755 tests/test_quick.py create mode 100644 tests/test_real_sec_vs_yfinance_plus.py create mode 100644 tests/test_sec_edgar_15year.py create mode 100644 tests/test_simple.py create mode 100644 tests/test_simple_real_data.py create mode 100644 tests/test_yfinance_comparison.py create mode 100644 tests/test_yfinance_fallback.py create mode 160000 yfinance_plus diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a2eda43 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitignore +.env +.claude +__pycache__ +*.pyc +.pytest_cache +tests/ +docs/ +examples/ +frontend/ +portainer/ +*.db +*.md +!requirements*.txt +data/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..590f8f2 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md new file mode 100644 index 0000000..077e3bd --- /dev/null +++ b/API_DOCUMENTATION.md @@ -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=` +- 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* ๐Ÿ”ฎ๐Ÿ“ˆ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..61c09f4 --- /dev/null +++ b/CHANGELOG.md @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..960620f --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..95fd127 --- /dev/null +++ b/README.md @@ -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 +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 ๐Ÿ”ฎ๐Ÿ“ˆ \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..a5fb74f --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# FastAPI SEC Investment API \ No newline at end of file diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..38e5c89 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +# API module \ No newline at end of file diff --git a/app/api/v1/api.py b/app/api/v1/api.py new file mode 100644 index 0000000..3f6c80e --- /dev/null +++ b/app/api/v1/api.py @@ -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"]) \ No newline at end of file diff --git a/app/api/v1/endpoints/__init__.py b/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..9435db6 --- /dev/null +++ b/app/api/v1/endpoints/__init__.py @@ -0,0 +1,3 @@ +from app.api.v1.endpoints import financial, price, catalog, health, migration + +__all__ = ["financial", "price", "catalog", "health", "migration"] \ No newline at end of file diff --git a/app/api/v1/endpoints/catalog.py b/app/api/v1/endpoints/catalog.py new file mode 100644 index 0000000..4bb9d40 --- /dev/null +++ b/app/api/v1/endpoints/catalog.py @@ -0,0 +1,406 @@ +""" +Data catalog endpoint +""" + +from datetime import datetime +from typing import Dict, List +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.schemas.financial import DataCatalogResponse, DataCatalogItem + +router = APIRouter() + +def get_data_catalog() -> Dict[str, List[DataCatalogItem]]: + """Get comprehensive data catalog""" + + catalog = { + "Company Information": [ + DataCatalogItem( + field_name="ticker", + description="Stock ticker symbol", + data_type="string", + unit=None, + calculation=None, + source="SEC EDGAR" + ), + DataCatalogItem( + field_name="name", + description="Company legal name", + data_type="string", + unit=None, + calculation=None, + source="SEC EDGAR" + ), + DataCatalogItem( + field_name="cik", + description="Central Index Key - SEC's unique identifier", + data_type="string", + unit=None, + calculation=None, + source="SEC EDGAR" + ), + DataCatalogItem( + field_name="sector", + description="Business sector classification", + data_type="string", + unit=None, + calculation=None, + source="SEC EDGAR / External" + ), + DataCatalogItem( + field_name="industry", + description="Industry classification", + data_type="string", + unit=None, + calculation=None, + source="SEC EDGAR / External" + ) + ], + + "Income Statement": [ + DataCatalogItem( + field_name="revenue", + description="Total revenue/sales for the period", + data_type="float", + unit="USD", + calculation=None, + source="SEC EDGAR 10-K/10-Q" + ), + DataCatalogItem( + field_name="gross_profit", + description="Revenue minus cost of goods sold", + data_type="float", + unit="USD", + calculation="Revenue - COGS", + source="SEC EDGAR 10-K/10-Q" + ), + DataCatalogItem( + field_name="operating_income", + description="Gross profit minus operating expenses", + data_type="float", + unit="USD", + calculation="Gross Profit - Operating Expenses", + source="SEC EDGAR 10-K/10-Q" + ), + DataCatalogItem( + field_name="net_income", + description="Bottom line profit after all expenses and taxes", + data_type="float", + unit="USD", + calculation="Operating Income - Interest - Taxes", + source="SEC EDGAR 10-K/10-Q" + ), + DataCatalogItem( + field_name="eps", + description="Earnings per share - basic", + data_type="float", + unit="USD per share", + calculation="Net Income / Shares Outstanding", + source="SEC EDGAR 10-K/10-Q" + ) + ], + + "Balance Sheet": [ + DataCatalogItem( + field_name="total_assets", + description="Total value of everything the company owns", + data_type="float", + unit="USD", + calculation="Current Assets + Non-Current Assets", + source="SEC EDGAR 10-K/10-Q" + ), + DataCatalogItem( + field_name="total_equity", + description="Shareholders' equity (Assets - Liabilities)", + data_type="float", + unit="USD", + calculation="Total Assets - Total Liabilities", + source="SEC EDGAR 10-K/10-Q" + ), + DataCatalogItem( + field_name="total_debt", + description="Long-term debt obligations", + data_type="float", + unit="USD", + calculation=None, + source="SEC EDGAR 10-K/10-Q" + ), + DataCatalogItem( + field_name="cash", + description="Cash and cash equivalents", + data_type="float", + unit="USD", + calculation=None, + source="SEC EDGAR 10-K/10-Q" + ), + DataCatalogItem( + field_name="shares_outstanding", + description="Number of shares currently held by shareholders", + data_type="float", + unit="shares", + calculation=None, + source="SEC EDGAR 10-K/10-Q" + ) + ], + + "Cash Flow Statement": [ + DataCatalogItem( + field_name="operating_cash_flow", + description="Cash generated from core business operations", + data_type="float", + unit="USD", + calculation="Net Income + Non-cash adjustments + Working Capital changes", + source="SEC EDGAR 10-K/10-Q" + ), + DataCatalogItem( + field_name="free_cash_flow", + description="Cash available after capital expenditures", + data_type="float", + unit="USD", + calculation="Operating Cash Flow - Capital Expenditures", + source="SEC EDGAR 10-K/10-Q" + ), + DataCatalogItem( + field_name="capex", + description="Capital expenditures for property, plant, and equipment", + data_type="float", + unit="USD", + calculation=None, + source="SEC EDGAR 10-K/10-Q" + ) + ], + + "Valuation Ratios": [ + DataCatalogItem( + field_name="pe_ratio", + description="Price-to-Earnings ratio (trailing)", + data_type="float", + unit="ratio", + calculation="Stock Price / EPS", + source="Calculated" + ), + DataCatalogItem( + field_name="pb_ratio", + description="Price-to-Book ratio", + data_type="float", + unit="ratio", + calculation="Market Cap / Total Equity", + source="Calculated" + ), + DataCatalogItem( + field_name="ps_ratio", + description="Price-to-Sales ratio", + data_type="float", + unit="ratio", + calculation="Market Cap / Revenue", + source="Calculated" + ), + DataCatalogItem( + field_name="ev_ebitda", + description="Enterprise Value to EBITDA ratio", + data_type="float", + unit="ratio", + calculation="Enterprise Value / EBITDA", + source="Calculated" + ) + ], + + "Profitability Metrics": [ + DataCatalogItem( + field_name="roe", + description="Return on Equity", + data_type="float", + unit="percentage", + calculation="(Net Income / Total Equity) ร— 100", + source="Calculated" + ), + DataCatalogItem( + field_name="roa", + description="Return on Assets", + data_type="float", + unit="percentage", + calculation="(Net Income / Total Assets) ร— 100", + source="Calculated" + ), + DataCatalogItem( + field_name="gross_margin", + description="Gross profit margin", + data_type="float", + unit="percentage", + calculation="(Gross Profit / Revenue) ร— 100", + source="Calculated" + ), + DataCatalogItem( + field_name="operating_margin", + description="Operating profit margin", + data_type="float", + unit="percentage", + calculation="(Operating Income / Revenue) ร— 100", + source="Calculated" + ), + DataCatalogItem( + field_name="net_margin", + description="Net profit margin", + data_type="float", + unit="percentage", + calculation="(Net Income / Revenue) ร— 100", + source="Calculated" + ) + ], + + "Growth Metrics": [ + DataCatalogItem( + field_name="revenue_growth_yoy", + description="Year-over-year revenue growth rate", + data_type="float", + unit="percentage", + calculation="((Current Year Revenue - Previous Year Revenue) / Previous Year Revenue) ร— 100", + source="Calculated" + ), + DataCatalogItem( + field_name="revenue_growth_qoq", + description="Quarter-over-quarter revenue growth rate", + data_type="float", + unit="percentage", + calculation="((Current Quarter Revenue - Previous Quarter Revenue) / Previous Quarter Revenue) ร— 100", + source="Calculated" + ), + DataCatalogItem( + field_name="eps_growth_yoy", + description="Year-over-year EPS growth rate", + data_type="float", + unit="percentage", + calculation="((Current Year EPS - Previous Year EPS) / Previous Year EPS) ร— 100", + source="Calculated" + ) + ], + + "Liquidity & Solvency": [ + DataCatalogItem( + field_name="debt_to_equity", + description="Debt-to-Equity ratio", + data_type="float", + unit="ratio", + calculation="Total Debt / Total Equity", + source="Calculated" + ), + DataCatalogItem( + field_name="debt_to_assets", + description="Debt-to-Assets ratio", + data_type="float", + unit="ratio", + calculation="Total Debt / Total Assets", + source="Calculated" + ), + DataCatalogItem( + field_name="current_ratio", + description="Current assets to current liabilities ratio", + data_type="float", + unit="ratio", + calculation="Current Assets / Current Liabilities", + source="Calculated" + ) + ], + + "Efficiency Metrics": [ + DataCatalogItem( + field_name="asset_turnover", + description="How efficiently company uses assets to generate revenue", + data_type="float", + unit="ratio", + calculation="Revenue / Average Total Assets", + source="Calculated" + ), + DataCatalogItem( + field_name="ocf_margin", + description="Operating cash flow margin", + data_type="float", + unit="percentage", + calculation="(Operating Cash Flow / Revenue) ร— 100", + source="Calculated" + ), + DataCatalogItem( + field_name="fcf_margin", + description="Free cash flow margin", + data_type="float", + unit="percentage", + calculation="(Free Cash Flow / Revenue) ร— 100", + source="Calculated" + ) + ], + + "Market Data (Future)": [ + DataCatalogItem( + field_name="market_cap", + description="Market capitalization", + data_type="float", + unit="USD", + calculation="Stock Price ร— Shares Outstanding", + source="Calculated (requires price data)" + ), + DataCatalogItem( + field_name="forward_pe", + description="Forward P/E ratio based on estimated earnings", + data_type="float", + unit="ratio", + calculation="Stock Price / Forward EPS Estimate", + source="External data required" + ), + DataCatalogItem( + field_name="peg_ratio", + description="Price/Earnings to Growth ratio", + data_type="float", + unit="ratio", + calculation="P/E Ratio / Earnings Growth Rate", + source="External data required" + ), + DataCatalogItem( + field_name="beta", + description="Stock's volatility relative to market", + data_type="float", + unit="coefficient", + calculation="Covariance(Stock Returns, Market Returns) / Variance(Market Returns)", + source="External price data required" + ) + ] + } + + return catalog + +@router.get( + "/catalog", + response_model=DataCatalogResponse, + summary="Get data catalog", + description=""" + Get a comprehensive catalog of all available data fields. + + This endpoint returns: + - All available financial metrics and their descriptions + - Data types and units for each field + - Calculation methods where applicable + - Data sources for each field + + The catalog is organized by categories: + - Company Information + - Income Statement + - Balance Sheet + - Cash Flow Statement + - Valuation Ratios + - Profitability Metrics + - Growth Metrics + - Liquidity & Solvency + - Efficiency Metrics + - Market Data (Future) + """ +) +async def get_catalog(db: AsyncSession = Depends(get_db)): + """Get comprehensive data catalog""" + + catalog = get_data_catalog() + + return DataCatalogResponse( + categories=catalog, + last_updated=datetime.utcnow() + ) \ No newline at end of file diff --git a/app/api/v1/endpoints/database.py b/app/api/v1/endpoints/database.py new file mode 100644 index 0000000..36b2239 --- /dev/null +++ b/app/api/v1/endpoints/database.py @@ -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") \ No newline at end of file diff --git a/app/api/v1/endpoints/error_logs.py b/app/api/v1/endpoints/error_logs.py new file mode 100644 index 0000000..b6a53a6 --- /dev/null +++ b/app/api/v1/endpoints/error_logs.py @@ -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 + } \ No newline at end of file diff --git a/app/api/v1/endpoints/etf.py b/app/api/v1/endpoints/etf.py new file mode 100644 index 0000000..7e88710 --- /dev/null +++ b/app/api/v1/endpoints/etf.py @@ -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. + \ No newline at end of file diff --git a/app/api/v1/endpoints/financial.py b/app/api/v1/endpoints/financial.py new file mode 100644 index 0000000..0e8fabd --- /dev/null +++ b/app/api/v1/endpoints/financial.py @@ -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() + } + ) \ No newline at end of file diff --git a/app/api/v1/endpoints/fred.py b/app/api/v1/endpoints/fred.py new file mode 100644 index 0000000..fcee92f --- /dev/null +++ b/app/api/v1/endpoints/fred.py @@ -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)}" + ) \ No newline at end of file diff --git a/app/api/v1/endpoints/health.py b/app/api/v1/endpoints/health.py new file mode 100644 index 0000000..d6c0509 --- /dev/null +++ b/app/api/v1/endpoints/health.py @@ -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() + ) \ No newline at end of file diff --git a/app/api/v1/endpoints/migration.py b/app/api/v1/endpoints/migration.py new file mode 100644 index 0000000..698783c --- /dev/null +++ b/app/api/v1/endpoints/migration.py @@ -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" + } \ No newline at end of file diff --git a/app/api/v1/endpoints/news.py b/app/api/v1/endpoints/news.py new file mode 100644 index 0000000..630ae15 --- /dev/null +++ b/app/api/v1/endpoints/news.py @@ -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." + ) \ No newline at end of file diff --git a/app/api/v1/endpoints/price.py b/app/api/v1/endpoints/price.py new file mode 100644 index 0000000..16a34a9 --- /dev/null +++ b/app/api/v1/endpoints/price.py @@ -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) \ No newline at end of file diff --git a/app/api/v1/endpoints/request_logs.py b/app/api/v1/endpoints/request_logs.py new file mode 100644 index 0000000..56a9b10 --- /dev/null +++ b/app/api/v1/endpoints/request_logs.py @@ -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 + } \ No newline at end of file diff --git a/app/api/v1/endpoints/stocks.py b/app/api/v1/endpoints/stocks.py new file mode 100644 index 0000000..35da68c --- /dev/null +++ b/app/api/v1/endpoints/stocks.py @@ -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)}" + ) \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..8caec28 --- /dev/null +++ b/app/core/config.py @@ -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.") \ No newline at end of file diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..3949d56 --- /dev/null +++ b/app/core/database.py @@ -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() \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..93c463d --- /dev/null +++ b/app/main.py @@ -0,0 +1,285 @@ +""" +Main FastAPI application +""" + +import os +from contextlib import asynccontextmanager +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse, HTMLResponse + +from app.core.config import settings +from app.api.v1.api import api_router +from app.core.database import engine, Base +from app.middleware.error_logger import ErrorLoggingMiddleware +from app.models import error_log, request_log, fred_data # Import to register models + +# Create database tables +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup - ensure tables exist + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + # Shutdown + await engine.dispose() + +# Create FastAPI app +app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + openapi_url=f"{settings.API_PREFIX}/openapi.json", + docs_url=f"{settings.API_PREFIX}/docs", + redoc_url=f"{settings.API_PREFIX}/redoc", + lifespan=lifespan +) + +# Add error logging middleware +app.add_middleware(ErrorLoggingMiddleware) + +# Set up CORS - use configured origins if available, otherwise allow all +cors_origins = [str(o) for o in settings.BACKEND_CORS_ORIGINS] if settings.BACKEND_CORS_ORIGINS else ["*"] +app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include API router +app.include_router(api_router, prefix=settings.API_PREFIX) + +# Root documentation endpoint +@app.get("/", response_class=HTMLResponse, include_in_schema=False) +async def root_documentation(): + """ + Display comprehensive API documentation at root path + """ + try: + # Simple working version + simple_html = f""" + + + + + + Stock Oracle API Documentation + + + +

๐Ÿ”ฎ Stock Oracle API Documentation

+

Comprehensive Investment Data Analysis API

+ + + +

๐Ÿš€ Quick Start

+

Base URL: http://localhost:18001/api/v1

+ +

๐ŸŽฏ Main Endpoints

+ +

Health & System

+
    +
  • GET /health - API health status
  • +
  • GET /health/detailed - Detailed system health
  • +
+ +

Financial Data

+
    +
  • POST /financial/data - Get comprehensive financial data
  • +
  • GET /financial/data/{{ticker}} - Simple financial data
  • +
  • POST /financial/data/bulk - Bulk financial data
  • +
+ +

Price Data

+
    +
  • POST /price/data - Get historical price data (OHLCV)
  • +
  • GET /price/data/{{ticker}} - Simple price data
  • +
  • POST /price/data/bulk - Bulk price data
  • +
  • GET /price/quote/{{ticker}} - Latest quote (regular/pre/post market)
  • +
  • GET /price/intraday/{{ticker}} - Intraday candles (interval, period)
  • +
  • GET /price/today/{{ticker}} - Today's OHLC (daily or 1m aggregate)
  • +
+ +

Stock Market Data NEW

+
    +
  • GET /stocks/trending - Trending stocks with intelligent parameter coordination (n=500 default)
  • +
  • GET /stocks/most-active - Most actively traded stocks
  • +
  • GET /stocks/52-week-gainers - Top 52-week gaining stocks
  • +
+ +

FRED Economic Data NEW

+
    +
  • GET /fred/proxy/{{endpoint}} - Universal FRED API proxy with caching
  • +
  • GET /fred/endpoints - List all supported FRED API endpoints
  • +
  • GET /fred/stats/usage - API usage statistics and monitoring
  • +
+ +

News & Social Media NEW

+
    +
  • GET /news/{{ticker}} - Complete news and social media data
  • +
  • GET /news/{{ticker}}/news-only - News articles only (faster)
  • +
  • GET /news/{{ticker}}/social-only - Social media posts only
  • +
+ +

ETF Holdings

+
    +
  • GET /etf/holdings/{{ticker}} - ETF holdings at date or most recent
  • +
  • POST /etf/admin/refresh-maps - Refresh CUSIP/CIK maps
  • +
+ +

๐Ÿ“Š Example Requests

+ +

Financial Data

+
curl -X POST "http://localhost:18001/api/v1/financial/data" \\
+  -H "Content-Type: application/json" \\
+  -d '{{"ticker": "AAPL", "period": "1y", "include_metrics": true}}'
+ +

Trending Stocks NEW

+
# Get trending stocks (default: 500 total stocks with intelligent coordination)
+curl "http://localhost:18001/api/v1/stocks/trending"
+
+# Custom total count
+curl "http://localhost:18001/api/v1/stocks/trending?n=200"
+ +

FRED Economic Data NEW

+
# Get GDP series information
+curl "http://localhost:18001/api/v1/fred/proxy/series?series_id=GDP"
+
+# Get unemployment rate observations
+curl "http://localhost:18001/api/v1/fred/proxy/series/observations?series_id=UNRATE&limit=12"
+ +

News & Social Data NEW

+
curl "http://localhost:18001/api/v1/news/AAPL?days_back=7&max_articles=20"
+ +

Price - Quote/Intraday/Today NEW

+
# Quote (latest regular/pre/post)
+curl "http://localhost:18001/api/v1/price/quote/AAPL?use_prepost=true"
+
+# Intraday 1m candles for 1 day
+curl "http://localhost:18001/api/v1/price/intraday/AAPL?interval=1m&period=1d"
+
+# Today's OHLC (daily if available; otherwise 1m aggregate)
+curl "http://localhost:18001/api/v1/price/today/AAPL"
+ +

ETF Holdings

+
curl "http://localhost:18001/api/v1/etf/holdings/QQQ"
+ +

๐Ÿ Python Client

+
from stock_oracle_client import StockOracleClient
+
+client = StockOracleClient("http://localhost:18001")
+
+# Check health
+health = client.get_health()
+print("API Status:", health["status"])
+
+# Get financial data
+data = client.get_financial_data("AAPL", period="1y")
+
+# Get news data (NEW!)
+news = client.get_news_social_data("AAPL", days_back=7)
+ +

๐Ÿ“ˆ Key Features

+
    +
  • SEC EDGAR Data - Official company filings (10-K, 10-Q)
  • +
  • Real-time News - Yahoo Finance + NewsAPI integration
  • +
  • Social Sentiment - Reddit discussions and sentiment analysis
  • +
  • ETF Holdings - Complete ETF portfolio analysis via N-PORT
  • +
  • Price Data - Historical OHLCV data from Yahoo Finance
  • +
  • Investment Metrics - P/E, ROE, debt ratios, growth metrics
  • +
+ +

๐Ÿ”ง Data Sources

+
    +
  • SEC EDGAR - Official company filings and ETF holdings
  • +
  • Yahoo Finance - Price data and financial news (via yfinance_plus)
  • +
  • NewsAPI - Professional news aggregation
  • +
  • Reddit API - Social media sentiment from investing subreddits
  • +
+ +
+

Stock Oracle API - Built with FastAPI, powered by SEC EDGAR data

+

For complete interactive documentation, visit Swagger UI

+

Version {settings.APP_VERSION}

+
+ + + """ + + return HTMLResponse(content=simple_html) + + except Exception as e: + # Fallback to Swagger UI if anything goes wrong + return RedirectResponse(url=f"{settings.API_PREFIX}/docs") + +# Additional metadata for OpenAPI +app.openapi_tags = [ + { + "name": "health", + "description": "Health check endpoints" + }, + { + "name": "financial", + "description": "Financial data retrieval endpoints" + }, + { + "name": "price", + "description": "Price data endpoints (OHLCV)" + }, + { + "name": "news", + "description": "News and social media endpoints" + }, + { + "name": "metadata", + "description": "Data catalog and metadata endpoints" + }, + { + "name": "etf", + "description": "ETF holdings endpoints" + }, + { + "name": "admin", + "description": "Administrative endpoints (migration, etc.)" + } +] + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=settings.API_PORT, + reload=settings.DEBUG + ) \ No newline at end of file diff --git a/app/middleware/error_logger.py b/app/middleware/error_logger.py new file mode 100644 index 0000000..d585530 --- /dev/null +++ b/app/middleware/error_logger.py @@ -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" + ) \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..f0260d3 --- /dev/null +++ b/app/models/__init__.py @@ -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", +] \ No newline at end of file diff --git a/app/models/error_log.py b/app/models/error_log.py new file mode 100644 index 0000000..fbd1e90 --- /dev/null +++ b/app/models/error_log.py @@ -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 + } \ No newline at end of file diff --git a/app/models/etf.py b/app/models/etf.py new file mode 100644 index 0000000..b5016cc --- /dev/null +++ b/app/models/etf.py @@ -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"), + ) + diff --git a/app/models/financial.py b/app/models/financial.py new file mode 100644 index 0000000..450374c --- /dev/null +++ b/app/models/financial.py @@ -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'), + ) \ No newline at end of file diff --git a/app/models/fred_data.py b/app/models/fred_data.py new file mode 100644 index 0000000..7d4f61b --- /dev/null +++ b/app/models/fred_data.py @@ -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'), + ) \ No newline at end of file diff --git a/app/models/request_log.py b/app/models/request_log.py new file mode 100644 index 0000000..c759d89 --- /dev/null +++ b/app/models/request_log.py @@ -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 + } \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..6422f5b --- /dev/null +++ b/app/schemas/__init__.py @@ -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" +] \ No newline at end of file diff --git a/app/schemas/error_log.py b/app/schemas/error_log.py new file mode 100644 index 0000000..2f97e01 --- /dev/null +++ b/app/schemas/error_log.py @@ -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) \ No newline at end of file diff --git a/app/schemas/financial.py b/app/schemas/financial.py new file mode 100644 index 0000000..942aa25 --- /dev/null +++ b/app/schemas/financial.py @@ -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 \ No newline at end of file diff --git a/app/schemas/request_log.py b/app/schemas/request_log.py new file mode 100644 index 0000000..edafdfb --- /dev/null +++ b/app/schemas/request_log.py @@ -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 \ No newline at end of file diff --git a/app/schemas/validators.py b/app/schemas/validators.py new file mode 100644 index 0000000..8c08c38 --- /dev/null +++ b/app/schemas/validators.py @@ -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 \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..f64cfd3 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,3 @@ +from app.services.sec_data_service import SECDataService + +__all__ = ["SECDataService"] \ No newline at end of file diff --git a/app/services/etf_holdings_fetcher.py b/app/services/etf_holdings_fetcher.py new file mode 100644 index 0000000..5db5286 --- /dev/null +++ b/app/services/etf_holdings_fetcher.py @@ -0,0 +1,1927 @@ +""" +ETF holdings fetcher: given ETF ticker and optional date, return holdings at that date +using the closest prior filing. Converts CUSIP -> ticker using local mapping. + +This fetcher uses SEC submissions endpoint to locate NPORT-P filings for the ETF's CIK, +and downloads the primary XML to parse holdings (CUSIP, shares, value). It then maps +CUSIP to ticker by local `cusip_map` table when possible. +""" + +from typing import List, Dict, Optional, Tuple +import time as _time +from datetime import datetime, timedelta, timezone +import aiohttp +import asyncio +from bs4 import BeautifulSoup +import re +import os +import json +import hashlib +import random +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, delete + +from app.models.etf import ETFCIKMap, CusipMap, ETFSeriesMap, ETFHoldingsSnapshot, ETFHolding +from app.core.config import settings + +# Optional external ETF scraper integration (provider websites) +try: + from etf_scraper import ETFScraper + _HAS_ETF_SCRAPER = True +except Exception: + _HAS_ETF_SCRAPER = False + + +class ETFHoldingsFetcher: + def __init__(self): + self.sec_base_data = "https://data.sec.gov" + self.sec_base_archives = "https://www.sec.gov/Archives/edgar/data" + # Keep HTTP timeouts conservative to avoid long waits in UI + self.http_timeout = aiohttp.ClientTimeout(total=12) + # Throttle and simple in-memory/disk cache to reduce 429s + # Allow small concurrency to speed up candidate evaluation while avoiding 429 + self._req_sem = asyncio.Semaphore(2) + self._text_cache: Dict[str, str] = {} + self._json_cache: Dict[str, dict] = {} + self._cache_dir = "/tmp/stock_oracle_sec_cache" + try: + os.makedirs(self._cache_dir, exist_ok=True) + except Exception: + pass + self._user_agent = f"Stock Oracle ETF Fetcher ({settings.SEC_EMAIL})" + # Per-request deadline (monotonic seconds). Set in get_holdings and honored by fetchers + self._deadline: Optional[float] = None + + def _normalize_snapshot_datetime(self, dt: datetime) -> datetime: + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.replace(hour=0, minute=0, second=0, microsecond=0) + + async def _persist_snapshot_and_holdings( + self, + db: AsyncSession, + *, + ticker: str, + snapshot_date: datetime, + source: str, + cik: Optional[str], + filing_accession: Optional[str], + xml_url: Optional[str], + metadata: Optional[Dict], + holdings: List[Dict], + ) -> Optional[str]: + try: + snap_dt = self._normalize_snapshot_datetime(snapshot_date) + existing = await db.execute( + select(ETFHoldingsSnapshot).where( + and_( + ETFHoldingsSnapshot.ticker == ticker, + ETFHoldingsSnapshot.snapshot_date == snap_dt, + ) + ) + ) + snapshot = existing.scalar_one_or_none() + if snapshot is None: + snapshot = ETFHoldingsSnapshot( + ticker=ticker, + snapshot_date=snap_dt, + source=source, + cik=cik, + filing_accession=filing_accession, + xml_url=xml_url, + metadata_json=metadata or {}, + ) + db.add(snapshot) + await db.flush() + else: + snapshot.source = source + snapshot.cik = cik + snapshot.filing_accession = filing_accession + snapshot.xml_url = xml_url + snapshot.metadata_json = metadata or {} + await db.execute(delete(ETFHolding).where(ETFHolding.snapshot_id == snapshot.id)) + + for h in holdings or []: + try: + db.add( + ETFHolding( + snapshot_id=snapshot.id, + name=h.get("name"), + cusip=h.get("cusip"), + ticker=h.get("ticker"), + shares=(float(h.get("shares")) if h.get("shares") is not None else None), + value=(float(h.get("value")) if h.get("value") is not None else None), + percentage=(float(h.get("percentage")) if h.get("percentage") is not None else None), + ) + ) + except Exception: + continue + await db.commit() + return str(snapshot.id) + except Exception: + try: + await db.rollback() + except Exception: + pass + return None + + async def _load_snapshot_holdings( + self, + db: AsyncSession, + *, + ticker: str, + as_of_date: Optional[datetime], + ) -> Optional[Dict]: + tkr = ticker.upper() + if as_of_date is not None: + dt_norm = self._normalize_snapshot_datetime(as_of_date) + q = select(ETFHoldingsSnapshot).where( + and_( + ETFHoldingsSnapshot.ticker == tkr, + ETFHoldingsSnapshot.snapshot_date <= dt_norm, + ) + ).order_by(ETFHoldingsSnapshot.snapshot_date.desc()).limit(1) + else: + q = select(ETFHoldingsSnapshot).where(ETFHoldingsSnapshot.ticker == tkr).order_by(ETFHoldingsSnapshot.snapshot_date.desc()).limit(1) + res = await db.execute(q) + snap = res.scalar_one_or_none() + if not snap: + return None + hres = await db.execute(select(ETFHolding).where(ETFHolding.snapshot_id == snap.id)) + rows = hres.scalars().all() + holdings = [] + for r in rows: + holdings.append({ + "name": r.name, + "cusip": r.cusip, + "ticker": r.ticker, + "shares": r.shares, + "value": r.value, + "percentage": r.percentage, + }) + return {"snapshot": snap, "holdings": holdings} + + def _quick_count_holdings(self, xml_text: str) -> int: + """Fast approximate count of holdings without full XML parsing. + Counts occurrences of the most common holding element names. + """ + if not xml_text: + return 0 + try: + # Simple lowercase search with regex allowing namespace prefixes + lt = xml_text.lower() + import re as _re + cnt = 0 + cnt += len(_re.findall(r"<[^>]*fundreportedholding", lt)) + cnt += len(_re.findall(r"<[^>]*invstorsec", lt)) + cnt += len(_re.findall(r"<[^>]*investmentorsec", lt)) + return cnt + except Exception: + return 0 + + def _cache_path(self, url: str) -> str: + h = hashlib.sha256(url.encode("utf-8")).hexdigest() + return os.path.join(self._cache_dir, h) + + async def _get_cik_record(self, db: AsyncSession, ticker: str) -> Optional[Dict[str, Optional[str]]]: + result = await db.execute(select(ETFCIKMap.cik, ETFCIKMap.name).where(ETFCIKMap.ticker == ticker.upper())) + row = result.first() + if not row: + return None + # series/class override if available + srow = await db.execute(select(ETFSeriesMap.series_id, ETFSeriesMap.class_id).where(ETFSeriesMap.ticker == ticker.upper())) + s = srow.first() + return {"cik": row[0], "name": row[1], "series_id": (s[0] if s else None), "class_id": (s[1] if s else None)} + + async def _fetch_json(self, url: str) -> dict: + # Simple retry with backoff on 429/5xx, with disk cache + attempts = 6 + backoff = 1.0 + last_exc = None + # Disk cache check + if url in self._json_cache: + return self._json_cache[url] + try: + cp = self._cache_path(url) + ".json" + if os.path.exists(cp): + # TTL based on SEC_DATA_REFRESH_HOURS + ttl_sec = max(3600, settings.SEC_DATA_REFRESH_HOURS * 3600) + if _time.time() - os.path.getmtime(cp) <= ttl_sec: + with open(cp, "r", encoding="utf-8") as f: + data = json.load(f) + self._json_cache[url] = data + return data + except Exception: + pass + for i in range(attempts): + now = _time.monotonic() + if self._deadline is not None and now >= self._deadline: + break + # Compute per-request remaining time and cap request timeout + req_timeout = self.http_timeout + if self._deadline is not None: + remaining = max(0.0, self._deadline - now) + if remaining < 0.25: + break + req_timeout = aiohttp.ClientTimeout(total=min(remaining, getattr(self.http_timeout, "total", 12))) + async with self._req_sem: + try: + async with aiohttp.ClientSession(timeout=req_timeout, headers={ + "User-Agent": self._user_agent, + "Accept": "application/json", + }) as session: + async with session.get(url, timeout=req_timeout) as resp: + if resp.status == 429: + retry_after = resp.headers.get("Retry-After") + delay = float(retry_after) if retry_after and retry_after.isdigit() else backoff + # Respect deadline + if self._deadline is not None: + remaining = max(0.0, self._deadline - _time.monotonic()) + delay = min(delay, max(0.0, remaining - 0.05)) + await asyncio.sleep(max(0.0, delay) + random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0)) + backoff *= 1.8 + continue + if 500 <= resp.status < 600: + delay = backoff + if self._deadline is not None: + remaining = max(0.0, self._deadline - _time.monotonic()) + delay = min(delay, max(0.0, remaining - 0.05)) + await asyncio.sleep(max(0.0, delay) + random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0)) + backoff *= 1.8 + continue + resp.raise_for_status() + data = await resp.json() + # cache successful + try: + with open(self._cache_path(url) + ".json", "w", encoding="utf-8") as f: + json.dump(data, f) + except Exception: + pass + self._json_cache[url] = data + return data + except Exception as e: + last_exc = e + delay = backoff + if self._deadline is not None: + remaining = max(0.0, self._deadline - _time.monotonic()) + delay = min(delay, max(0.0, remaining - 0.05)) + await asyncio.sleep(max(0.0, delay) + random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0)) + backoff *= 1.8 + continue + raise last_exc if last_exc else RuntimeError("Failed to fetch JSON") + + async def _fetch_text(self, url: str) -> str: + def _is_sec_block_page(text: str) -> bool: + if not text: + return False + tl = text.lower() + if "your request originates from an undeclared automated tool" in tl: + return True + if "sec.gov | your request originates" in tl: + return True + if "reference id:" in tl and "sec.gov" in tl: + return True + return False + + if url in self._text_cache: + return self._text_cache[url] + attempts = 6 + backoff = 1.0 + last_exc = None + # Disk cache check + try: + cp = self._cache_path(url) + ".txt" + if os.path.exists(cp): + ttl_sec = max(3600, settings.SEC_DATA_REFRESH_HOURS * 3600) + if _time.time() - os.path.getmtime(cp) <= ttl_sec: + with open(cp, "r", encoding="utf-8") as f: + text = f.read() + if _is_sec_block_page(text): + try: + os.remove(cp) + except Exception: + pass + else: + self._text_cache[url] = text + return text + except Exception: + pass + for i in range(attempts): + now = _time.monotonic() + if self._deadline is not None and now >= self._deadline: + break + # Compute per-request remaining time and cap request timeout + req_timeout = self.http_timeout + if self._deadline is not None: + remaining = max(0.0, self._deadline - now) + if remaining < 0.25: + break + req_timeout = aiohttp.ClientTimeout(total=min(remaining, getattr(self.http_timeout, "total", 12))) + async with self._req_sem: + try: + async with aiohttp.ClientSession(timeout=req_timeout, headers={ + "User-Agent": self._user_agent, + "Accept": "application/xml, text/xml;q=0.9, text/html;q=0.8", + }) as session: + async with session.get(url, timeout=req_timeout) as resp: + if resp.status == 429: + retry_after = resp.headers.get("Retry-After") + delay = float(retry_after) if retry_after and retry_after.isdigit() else backoff + if self._deadline is not None: + remaining = max(0.0, self._deadline - _time.monotonic()) + delay = min(delay, max(0.0, remaining - 0.05)) + await asyncio.sleep(max(0.0, delay) + random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0)) + backoff *= 1.8 + continue + if 500 <= resp.status < 600: + delay = backoff + if self._deadline is not None: + remaining = max(0.0, self._deadline - _time.monotonic()) + delay = min(delay, max(0.0, remaining - 0.05)) + await asyncio.sleep(max(0.0, delay) + random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0)) + backoff *= 1.8 + continue + resp.raise_for_status() + text = await resp.text() + # Detect SEC block page and treat as transient error + if _is_sec_block_page(text): + last_exc = RuntimeError("SEC_BLOCKED") + delay = backoff * 2.0 + if self._deadline is not None: + remaining = max(0.0, self._deadline - _time.monotonic()) + delay = min(delay, max(0.0, remaining - 0.05)) + await asyncio.sleep(max(0.0, delay) + random.uniform(0.0, delay * 0.5 if delay > 0 else 0.0)) + backoff *= 2.0 + continue + # cache only successful responses + self._text_cache[url] = text + try: + with open(self._cache_path(url) + ".txt", "w", encoding="utf-8") as f: + f.write(text) + except Exception: + pass + return text + except Exception as e: + last_exc = e + delay = backoff + if self._deadline is not None: + remaining = max(0.0, self._deadline - _time.monotonic()) + delay = min(delay, max(0.0, remaining - 0.05)) + await asyncio.sleep(max(0.0, delay) + random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0)) + backoff *= 1.8 + continue + raise last_exc if last_exc else RuntimeError("Failed to fetch text") + + async def _find_best_filing_and_xml( + self, + cik: str, + target_date: Optional[datetime], + *, + ticker: Optional[str] = None, + fund_name: Optional[str] = None, + series_id: Optional[str] = None, + class_id: Optional[str] = None, + scan_limit: int = 18, + ) -> Optional[Tuple[str, str, str]]: + """Return (filing_date, accession_number, xml_url) for closest prior NPORT-P filing. + + For trust-level CIKs with many series, scan recent accessions and pick the XML whose + <seriesName> best matches the fund_name tokens or whose index hints include the ticker. + """ + cik_digits = "".join(ch for ch in str(cik) if ch.isdigit()) + if not cik_digits: + return None + url = f"{self.sec_base_data}/submissions/CIK{int(cik_digits):010d}.json" + data = await self._fetch_json(url) + filings: List[Tuple[datetime, str]] = [] + def add_from_block(block: dict): + forms = block.get("form", []) + dates = block.get("filingDate", []) + accessions = block.get("accessionNumber", []) + for form, dt_str, acc in zip(forms, dates, accessions): + if form != "NPORT-P": + continue + try: + dt = datetime.strptime(dt_str, "%Y-%m-%d").replace(tzinfo=timezone.utc) + except Exception: + continue + filings.append((dt, acc)) + + recent = data.get("filings", {}).get("recent", {}) + add_from_block(recent) + + # If target_date is earlier than the earliest in recent, fetch older yearly submission files + files_meta = data.get("filings", {}).get("files", []) or [] + if target_date and filings: + earliest_dt = min(dt for dt, _ in filings) + if earliest_dt > target_date and files_meta: + # Try up to 6 older files + for meta in files_meta[:6]: + name = meta.get("name") + if not name: + continue + # name looks like 'CIK0001100663-2020.json' + older_url = f"{self.sec_base_data}/submissions/{name}" + try: + older = await self._fetch_json(older_url) + older_recent = older.get("filings", {}).get("recent", {}) + add_from_block(older_recent) + earliest_dt = min(dt for dt, _ in filings) + if earliest_dt <= target_date: + break + except Exception: + continue + if not filings: + return None + filings.sort(key=lambda x: x[0]) + + # Candidate list: newest first, filtered by date if provided. + # Ensure we include multiple prior days (not just the top day) to avoid missing the target series. + if target_date: + eligible = [(dt, acc) for dt, acc in filings if dt <= target_date] + if not eligible: + early_dt, early_acc = filings[0] + xml_url = await self._download_primary_xml( + cik_digits, + early_acc, + ticker=ticker, + fund_name=fund_name, + series_id=series_id, + class_id=class_id, + ) + return (early_dt.strftime("%Y-%m-%d"), early_acc, xml_url) + else: + eligible = filings + eligible_rev = list(reversed(eligible)) + # Group by date (YYYY-MM-DD) + grouped: Dict[str, List[Tuple[datetime, str]]] = {} + for dt, acc in eligible_rev: + key = dt.strftime("%Y-%m-%d") + grouped.setdefault(key, []).append((dt, acc)) + # Interleave per-day groups: take up to per_day_limit from each day, then next day, etc. + per_day_limit = 60 + total_limit = max(200, scan_limit * 10) + cands: List[Tuple[datetime, str]] = [] + for day_key in grouped.keys(): + day_items = grouped[day_key][:per_day_limit] + cands.extend(day_items) + if len(cands) >= total_limit: + break + + # Establish time budget for the remainder of this selection + deadline = _time.monotonic() + 22.0 + + # Primary-doc first pass: avoid index.htm to reduce 429s. + # Try a limited number of accessions, fetch primary XML(s), and score by series match and holdings count. + sid = (series_id or "").strip() + cid = (class_id or "").strip() if class_id else None + primary_limit = 40 + best_primary: Optional[Tuple[int, datetime, str, str]] = None # (score, dt, acc, url) + for dt, acc in cands[:primary_limit]: + if _time.monotonic() > deadline: + break + acc_clean = acc.replace("-", "") + for rel in ("primary_doc.xml", "xslFormNPORT-P_X01/primary_doc.xml"): + if _time.monotonic() > deadline: + break + try: + base_url = f"{self.sec_base_archives}/{int(cik_digits)}/{acc_clean}/" + url = base_url + rel + xml_text = await self._fetch_text(url) + score = 0 + if sid and sid in xml_text: + score += 120 + if cid and cid in xml_text: + score += 40 + # Token-based signal (partial match threshold) + if fund_name: + fname_l = fund_name.strip().lower() + toks = [tok for tok in fname_l.replace("(", " ").replace(")", " ").replace(",", " ").split() if len(tok) >= 3] + if toks: + low = xml_text.lower() + matched = sum(1 for tok in toks if tok in low) + if matched >= max(2, len(toks) // 2): + score += 30 + # Quick approximate holdings count to avoid heavy parsing in this pass + try: + cnt = self._quick_count_holdings(xml_text) + score += max(0, 40 - abs(cnt - 129)) + if 80 <= cnt <= 200: + # Return early only if we also have a strong identity signal + strong_match = False + if sid and sid in xml_text: + strong_match = True + if cid and cid in xml_text: + strong_match = True + if not strong_match and fund_name: + fname_l = fund_name.strip().lower() + toks = [tok for tok in fname_l.replace("(", " ").replace(")", " ").replace(",", " ").split() if len(tok) >= 3] + if toks: + low = xml_text.lower() + matched = sum(1 for tok in toks if tok in low) + if matched >= max(2, len(toks) // 2): + strong_match = True + if strong_match: + return (dt.strftime("%Y-%m-%d"), acc, url) + except Exception: + pass + if best_primary is None or score > best_primary[0] or (score == best_primary[0] and dt > best_primary[1]): + best_primary = (score, dt, acc, url) + except Exception: + continue + + # Prepare tokens + fname = (fund_name or "").strip().lower() + tokens = [tok for tok in fname.replace("(", " ").replace(")", " ").replace(",", " ").split() if len(tok) >= 3] + if ticker: + tkn = ticker.strip().lower() + if tkn and tkn not in tokens: + tokens.append(tkn) + + # Try to find matching seriesName in XML for each accession; also test alternates when holdings too low + fallback: Optional[Tuple[str, str, str]] = None + sid = (series_id or "").strip() + cid = (class_id or "").strip() + # Build list of (dt, acc, candidate_urls) + filing_candidates: List[Tuple[datetime, str, List[str]]] = [] + for dt, acc in cands: + if _time.monotonic() > deadline: + break + try: + urls = await self._list_candidate_docs( + cik_digits, + acc, + ticker=ticker, + fund_name=fund_name, + series_id=series_id, + class_id=class_id, + ) + if urls: + filing_candidates.append((dt, acc, urls[:10])) + except Exception: + continue + + # Flatten URLs with priority (newer filings first) + flat: List[Tuple[datetime, str, str]] = [] + for dt, acc, urls in filing_candidates: + for u in urls: + flat.append((dt, acc, u)) + + # Limit total candidates to avoid long scans + flat = flat[:60] + + # Concurrently evaluate candidates with a small pool + from asyncio import Semaphore, create_task, wait, FIRST_COMPLETED + sem = Semaphore(6) + + async def eval_candidate(dt: datetime, acc: str, url: str): + if _time.monotonic() > deadline: + return None + async with sem: + try: + xml_text = await self._fetch_text(url) + # Score + score = 0 + # Fast-path: exact series/class id substring in XML text + if sid and sid in xml_text: + score += 200 + if cid and cid in xml_text: + score += 50 + # Token hint from fund name in raw text to avoid full parse (partial match) + if tokens: + low = xml_text.lower() + matched = sum(1 for tok in tokens if tok in low) + if matched >= max(2, len(tokens) // 2): + score += 30 + # Approximate holdings count quickly to avoid expensive parsing per candidate + try: + cnt = self._quick_count_holdings(xml_text) + # Prefer close to 129 + score += max(0, 40 - abs(cnt - 129)) + # Penalize very large counts + if cnt > 400: + score -= 120 + except Exception: + cnt = 0 + return (score, dt, acc, url) + except Exception: + return None + + tasks = [create_task(eval_candidate(dt, acc, url)) for dt, acc, url in flat] + best: Optional[Tuple[int, datetime, str, str]] = None + for t in tasks: + if _time.monotonic() > deadline: + break + done, pending = await wait(tasks, timeout=0.2, return_when=FIRST_COMPLETED) + for d in done: + res = d.result() + if res is None: + continue + if best is None or res[0] > best[0] or (res[0] == best[0] and res[1] > best[1]): + best = res + if best and best[0] >= 100: + # Exact series match found + break + + # Cancel remaining tasks + for t in tasks: + if not t.done(): + t.cancel() + + if best: + _, dt, acc, url = best + return (dt.strftime("%Y-%m-%d"), acc, url) + + # If primary pass produced a fallback, validate it has holdings; otherwise, try index candidates for that accession + if best_primary: + _, dt, acc, url = best_primary + try: + xml_text = await self._fetch_text(url) + cnt = self._quick_count_holdings(xml_text) + strong_match = False + if sid and sid in xml_text: + strong_match = True + if cid and cid in xml_text: + strong_match = True + if not strong_match and fund_name: + fname_l = fund_name.strip().lower() + toks = [tok for tok in fname_l.replace("(", " ").replace(")", " ").replace(",", " ").split() if len(tok) >= 3] + if toks: + low = xml_text.lower() + matched = sum(1 for tok in toks if tok in low) + if matched >= max(2, len(toks) // 2): + strong_match = True + except Exception: + cnt = 0 + strong_match = False + if cnt >= 50 and strong_match: + return (dt.strftime("%Y-%m-%d"), acc, url) + # Try index candidates for this accession with stronger size/score preference and pick first with decent count + try: + cand_urls = await self._list_candidate_docs(cik_digits, acc, ticker=ticker, fund_name=fund_name, series_id=series_id, class_id=class_id) + best_cand: Optional[Tuple[int, str]] = None # (cnt, url) + for cu in cand_urls[:20]: + try: + xt = await self._fetch_text(cu) + c = self._quick_count_holdings(xt) + if c >= 80: + return (dt.strftime("%Y-%m-%d"), acc, cu) + if best_cand is None or c > best_cand[0]: + best_cand = (c, cu) + except Exception: + continue + if best_cand is not None and best_cand[0] > 0: + return (dt.strftime("%Y-%m-%d"), acc, best_cand[1]) + except Exception: + pass + + if fallback: + return fallback + # Last resort: use best candidate from index (not necessarily primary) of newest prior + dt, acc = cands[0] + try: + cand_urls = await self._list_candidate_docs(cik_digits, acc, ticker=ticker, fund_name=fund_name, series_id=series_id, class_id=class_id) + best_cand: Optional[Tuple[int, str]] = None + for cu in cand_urls[:20]: + try: + xt = await self._fetch_text(cu) + c = self._quick_count_holdings(xt) + if c >= 80: + return (dt.strftime("%Y-%m-%d"), acc, cu) + if best_cand is None or c > best_cand[0]: + best_cand = (c, cu) + except Exception: + continue + # Fallback to primary if nothing else + xml_url = best_cand[1] if (best_cand and best_cand[0] > 0) else await self._download_primary_xml(cik_digits, acc, ticker=ticker, fund_name=fund_name, series_id=series_id, class_id=class_id) + return (dt.strftime("%Y-%m-%d"), acc, xml_url) + except Exception: + xml_url = await self._download_primary_xml(cik_digits, acc, ticker=ticker, fund_name=fund_name, series_id=series_id, class_id=class_id) + return (dt.strftime("%Y-%m-%d"), acc, xml_url) + + async def _download_primary_xml(self, cik: str, accession_number: str, ticker: Optional[str] = None, fund_name: Optional[str] = None, series_id: Optional[str] = None, class_id: Optional[str] = None) -> Optional[str]: + """Find a primary XML URL for a filing. Prefer direct primary paths to avoid index 429.""" + # CIK in archives path is typically non-padded digits + cik_digits = "".join(ch for ch in str(cik) if ch.isdigit()) + acc_clean = accession_number.replace("-", "") + base_dir = f"{self.sec_base_archives}/{int(cik_digits)}/{acc_clean}/" + # First try direct primary paths + for rel in ("primary_doc.xml", "xslFormNPORT-P_X01/primary_doc.xml"): + try: + url = base_dir + rel + _ = await self._fetch_text(url) + return url + except Exception: + continue + # Fallback to index page if direct paths fail + index_url = f"{base_dir}{accession_number}-index.htm" + html = await self._fetch_text(index_url) + soup = BeautifulSoup(html, "html.parser") + + def mk_abs(href: str) -> str: + if href.startswith("/"): + return f"https://www.sec.gov{href}" + return f"{index_url.rsplit('/', 1)[0]}/{href}" + + # Collect candidate XML docs that likely contain full holdings + candidates = [] # list of (score, size_bytes, url) + + def parse_size(text: str) -> int: + t = (text or "").upper().strip() + num = 0.0 + unit = 1 + parts = t.split() + if not parts: + return 0 + try: + num = float(parts[0]) + except Exception: + return 0 + if len(parts) > 1: + u = parts[1] + if u.startswith("KB"): + unit = 1024 + elif u.startswith("MB"): + unit = 1024 * 1024 + elif u.startswith("B"): + unit = 1 + return int(num * unit) + + tkr = (ticker or "").strip().lower() + fname = (fund_name or "").strip().lower() + # Tokenize fund name to boost relevance + name_tokens = [tok for tok in fname.replace("(", " ").replace(")", " ").replace(",", " ").split() if len(tok) >= 3] + sid = (series_id or "").strip().lower() if series_id else "" + cid = (class_id or "").strip().lower() if class_id else "" + for row in soup.find_all("tr"): + cells = row.find_all(["td", "th"]) + if len(cells) < 4: + continue + desc = cells[1].get_text(strip=True) if len(cells) > 1 else "" + doc_cell = cells[2] + typ = cells[3].get_text(strip=True) + size_text = cells[4].get_text(strip=True) if len(cells) > 4 else "" + a = doc_cell.find("a") + if not a or not a.get("href"): + continue + href = a["href"] + doc_name = a.get_text(strip=True) or doc_cell.get_text(strip=True) + lower_name = (doc_name or "").lower() + lower_desc = (desc or "").lower() + size_bytes = parse_size(size_text) + + # Prefer XML docs, type NPORT-P, and names containing 'nport' + is_xml = lower_name.endswith(".xml") + is_txt = lower_name.endswith(".txt") + score = 0 + if typ.upper().startswith("NPORT-P"): + score += 5 + if "nport" in lower_name or "nport" in lower_desc: + score += 3 + # Strongly prefer docs that mention the requested ticker + if tkr and (tkr in lower_name or tkr in lower_desc): + score += 6 + # Boost if fund name tokens appear + if name_tokens and any(tok in lower_name or tok in lower_desc for tok in name_tokens): + score += 4 + if sid and sid in (lower_name + " " + lower_desc): + score += 8 + if cid and cid in (lower_name + " " + lower_desc): + score += 5 + # Boost if series/class id appears in name or description + if series_id and series_id.lower() in (lower_name + " " + lower_desc): + score += 8 + if class_id and class_id.lower() in (lower_name + " " + lower_desc): + score += 5 + if is_xml: + score += 2 + if is_txt: + score += 1 + if score > 0 and (is_xml or is_txt): + candidates.append((score, size_bytes, mk_abs(href))) + + if candidates: + # Pick highest score, break ties by largest size + candidates.sort(key=lambda x: (x[0], x[1]), reverse=True) + return candidates[0][2] + + # Fallback: any XML in index + for a in soup.find_all("a"): + href = a.get("href") or "" + if href.lower().endswith(".xml") and "nport" in href.lower(): + return mk_abs(href) + + # Final fallback common path + base = index_url.rsplit("/", 1)[0] + return f"{base}/primary_doc.xml" + + async def _list_candidate_docs(self, cik: str, accession_number: str, ticker: Optional[str] = None, fund_name: Optional[str] = None, series_id: Optional[str] = None, class_id: Optional[str] = None) -> list: + """Return a sorted list of candidate document URLs from the index page, best first.""" + cik_digits = "".join(ch for ch in str(cik) if ch.isdigit()) + acc_clean = accession_number.replace("-", "") + index_url = f"{self.sec_base_archives}/{int(cik_digits)}/{acc_clean}/{accession_number}-index.htm" + html = await self._fetch_text(index_url) + soup = BeautifulSoup(html, "html.parser") + + def mk_abs(href: str) -> str: + if href.startswith("/"): + return f"https://www.sec.gov{href}" + return f"{index_url.rsplit('/', 1)[0]}/{href}" + + def parse_size(text: str) -> int: + t = (text or "").upper().strip() + num = 0.0 + unit = 1 + parts = t.split() + if not parts: + return 0 + try: + num = float(parts[0]) + except Exception: + return 0 + if len(parts) > 1: + u = parts[1] + if u.startswith("KB"): + unit = 1024 + elif u.startswith("MB"): + unit = 1024 * 1024 + elif u.startswith("B"): + unit = 1 + return int(num * unit) + + tkr = (ticker or "").strip().lower() + fname = (fund_name or "").strip().lower() + name_tokens = [tok for tok in fname.replace("(", " ").replace(")", " ").replace(",", " ").split() if len(tok) >= 3] + sid = (series_id or "").strip().lower() + cid = (class_id or "").strip().lower() + + scored = [] + for row in soup.find_all("tr"): + cells = row.find_all(["td", "th"]) + if len(cells) < 4: + continue + desc = cells[1].get_text(strip=True) if len(cells) > 1 else "" + doc_cell = cells[2] + typ = cells[3].get_text(strip=True) + size_text = cells[4].get_text(strip=True) if len(cells) > 4 else "" + a = doc_cell.find("a") + if not a or not a.get("href"): + continue + href = a["href"] + doc_name = a.get_text(strip=True) or doc_cell.get_text(strip=True) + lower_name = (doc_name or "").lower() + lower_desc = (desc or "").lower() + size_bytes = parse_size(size_text) + + is_xml = lower_name.endswith(".xml") + is_txt = lower_name.endswith(".txt") + if not (is_xml or is_txt): + continue + score = 0 + if typ.upper().startswith("NPORT-P"): + score += 5 + if "nport" in lower_name or "nport" in lower_desc: + score += 3 + if tkr and (tkr in lower_name or tkr in lower_desc): + score += 6 + if name_tokens and any(tok in lower_name or tok in lower_desc for tok in name_tokens): + score += 4 + # Boost if series/class id appears in name or description + if sid and sid in (lower_name + " " + lower_desc): + score += 8 + if cid and cid in (lower_name + " " + lower_desc): + score += 5 + if is_xml: + score += 2 + if is_txt: + score += 1 + scored.append((score, size_bytes, mk_abs(href))) + + if not scored: + # Fallback to any XML containing nport + for a in soup.find_all("a"): + href = a.get("href") or "" + if href.lower().endswith(".xml") and "nport" in href.lower(): + scored.append((1, 0, mk_abs(href))) + + scored.sort(key=lambda x: (x[0], x[1]), reverse=True) + return [u for _, __, u in scored] + + async def _parse_holdings_from_xml( + self, + xml_text: str, + *, + filter_series_id: Optional[str] = None, + filter_class_id: Optional[str] = None, + filter_series_tokens: Optional[List[str]] = None, + ) -> List[Dict]: + """Parse NPORT-P XML to extract holdings list with fields: name, cusip, shares, value, pct. + + When filter parameters are provided, only holdings that belong to the matching series/class + context in the XML (based on nearest ancestor tags: seriesId, classId, seriesName) are kept. + """ + # If a specific series is requested, first try to isolate the series slice + def extract_series_slices(xml: str, series_id: str) -> List[str]: + sid = re.escape(series_id) + # Allow namespace prefixes on tags like ns:seriesId and ns:edgarSubmission + pattern = re.compile( + rf"(<[^>]*seriesId[^>]*>\s*{sid}\s*</[^>]*seriesId[^>]*>[\s\S]*?)(?=<[^>]*seriesId[^>]*>|</[^>]*edgarSubmission[^>]*>|\Z)", + re.IGNORECASE, + ) + return [m.group(1) for m in pattern.finditer(xml)] + + if filter_series_id: + slices = extract_series_slices(xml_text, filter_series_id) + # If we found slices, parse each slice independently and pick the best + if slices: + best: Optional[List[Dict]] = None + best_score = -10**9 + for sl in slices: + res = await self._parse_holdings_from_xml( + sl, + filter_series_id=None, # already sliced + filter_class_id=filter_class_id, + filter_series_tokens=filter_series_tokens, + ) + # Prefer counts in ETF-like range, closest to 129 best + cnt = len(res) + score = -abs(cnt - 129) + if 50 <= cnt <= 400: + score += 10 + if cnt and score > best_score: + best_score = score + best = res + if best is not None: + return best + + # If tokens are provided but series_id is not available in the XML, attempt to slice by seriesName tokens + def extract_seriesname_token_slices(xml: str, tokens: List[str]) -> List[str]: + toks = [t.lower() for t in tokens if len(t) >= 3] + if not toks: + return [] + # Find all seriesName tag blocks with positions + pattern = re.compile(r"<[^>]*seriesName[^>]*>[\s\S]*?</[^>]*seriesName[^>]*>", re.IGNORECASE) + matches = list(pattern.finditer(xml)) + slices: List[str] = [] + if not matches: + return [] + lowers = xml.lower() + for idx, m in enumerate(matches): + start = m.start() + end = m.end() + # Inner text of this seriesName + block = xml[m.start():m.end()] + # crude inner text extraction + inner = re.sub(r"<[^>]+>", "", block) + inner_l = inner.strip().lower() + matched = sum(1 for t in toks if t in inner_l) + if matched >= max(2, len(toks)//2): + # Slice from this seriesName to next seriesName (or end of document) + nxt_start = matches[idx+1].start() if (idx+1) < len(matches) else len(xml) + sl = xml[start:nxt_start] + slices.append(sl) + return slices + + # seriesName-token slicing when series_id filtering not specified or failed later + seriesname_token_slices: List[str] = [] + if not filter_series_id and filter_series_tokens: + try: + seriesname_token_slices = extract_seriesname_token_slices(xml_text, filter_series_tokens) + except Exception: + seriesname_token_slices = [] + + soup = BeautifulSoup(xml_text, "xml") + holdings: List[Dict] = [] + investment_tags = [ + "fundReportedHolding", + "invstOrSec", + "investmentOrSec", + "investment", + "holding", + "security", + ] + target_sid = (filter_series_id or "").strip() + target_cid = (filter_class_id or "").strip() + tokens = [t.lower() for t in (filter_series_tokens or []) if len(t) >= 3] + + def _name_endswith(tag, suffix: str) -> bool: + try: + return hasattr(tag, "name") and isinstance(tag.name, str) and tag.name.lower().endswith(suffix.lower()) + except Exception: + return False + + def find_series_scopes() -> List: + scopes: List = [] + # 1) Exact series_id match + if target_sid: + for sid_node in soup.find_all(lambda t: _name_endswith(t, "seriesid")): + try: + if sid_node.get_text(strip=True) != target_sid: + continue + # climb up to a container that contains holdings + ancestor = sid_node + for _ in range(30): + ancestor = ancestor.parent + if ancestor is None: + break + if any(ancestor.find(tag) for tag in investment_tags): + scopes.append(ancestor) + break + except Exception: + continue + # 2) class_id match if provided + if target_cid: + for cid_node in soup.find_all(lambda t: _name_endswith(t, "classid")): + try: + if cid_node.get_text(strip=True) != target_cid: + continue + ancestor = cid_node + for _ in range(30): + ancestor = ancestor.parent + if ancestor is None: + break + if any(ancestor.find(tag) for tag in investment_tags): + scopes.append(ancestor) + break + except Exception: + continue + # 3) seriesName tokens + if tokens and not scopes: + for sn in soup.find_all(lambda t: _name_endswith(t, "seriesname")): + try: + s = sn.get_text(strip=True).lower() + if not all(tok in s for tok in tokens): + continue + ancestor = sn + for _ in range(30): + ancestor = ancestor.parent + if ancestor is None: + break + if any(ancestor.find(tag) for tag in investment_tags): + scopes.append(ancestor) + break + except Exception: + continue + # 4) fallback: whole document only when no explicit series/class filter + if not scopes and not target_sid and not target_cid: + scopes = [soup] + return scopes + + scopes = find_series_scopes() + # If no scopes found but we have token-based slices, use those as scopes by creating soups of each slice + if not scopes and seriesname_token_slices: + scopes = [BeautifulSoup(sl, "xml") for sl in seriesname_token_slices] + + # If no specific filter provided and trust-level XML contains multiple series, + # attempt per-series parsing and choose the best by closeness to target holdings count. + if not target_sid and not target_cid: + series_nodes = soup.find_all("seriesId") + if series_nodes and len(series_nodes) > 1: + unique_series: List[str] = [] + for sn in series_nodes: + try: + v = sn.get_text(strip=True) + except Exception: + v = None + if v and v not in unique_series: + unique_series.append(v) + best_local: Optional[List[Dict]] = None + best_score = -10**9 + target_count = 129 + for sid in unique_series[:40]: + try: + parsed = await self._parse_holdings_from_xml( + xml_text, + filter_series_id=sid, + filter_class_id=None, + filter_series_tokens=filter_series_tokens, + ) + except Exception: + parsed = [] + cnt = len(parsed) + score = -abs(cnt - target_count) + if 60 <= cnt <= 220: + score += 10 + if parsed and score > best_score: + best_score = score + best_local = parsed + # Early stop if exact match + if cnt == target_count: + best_local = parsed + break + if best_local is not None and len(best_local) >= 1: + return best_local + parsed_any = False + best_scope_result: Optional[List[Dict]] = None + best_scope_score: int = -10**9 + target_count = 129 + for scope in scopes: + # Prefer 'fundReportedHolding' nodes (allow namespace), otherwise fallback to other tags (allow namespace) + elems: List = [] + frh = scope.find_all(lambda t: _name_endswith(t, "fundreportedholding")) + if frh: + elems = frh + else: + for tag in investment_tags: + items = scope.find_all(lambda t, tg=tag: _name_endswith(t, tg)) + if items: + elems.extend(items) + if not elems: + continue + try: + local_holdings: List[Dict] = [] + for inv in elems: + # Attribute-based series/class hint on each holding node + inv_attrs = {k.lower(): str(v) for k, v in (inv.attrs or {}).items()} + # Skip if attributes explicitly point to a different series/class + if target_sid and any(("series" in k and target_sid not in v) for k, v in inv_attrs.items()): + continue + if target_cid and any(("class" in k and target_cid not in v) for k, v in inv_attrs.items()): + continue + def find_text(inv_node, candidates: List[str]) -> Optional[str]: + for cand in candidates: + el = inv_node.find(lambda t: _name_endswith(t, cand)) + if el and el.get_text(strip=True): + return el.get_text(strip=True) + return None + + name = find_text(inv, ["name", "issuerName", "secName", "title"]) + cusip = find_text(inv, ["cusip", "cusip9"]) + shares_str = find_text(inv, ["shares", "balance"]) + shares = None + if shares_str: + try: + shares = float(shares_str.replace(",", "")) + except Exception: + shares = None + value_str = find_text(inv, ["valUSD", "marketValue", "value"]) + value = None + if value_str: + try: + value = float(value_str.replace(",", "")) + except Exception: + value = None + pct_str = find_text(inv, ["pctVal", "PercentageOfNetAssets", "percentage"]) + pct = None + if pct_str: + try: + pct = float(pct_str.replace("%", "").replace(",", "")) + except Exception: + pct = None + + def norm_cusip(c: Optional[str]) -> Optional[str]: + if not c: + return None + cc = "".join(ch for ch in c.upper() if ch.isalnum()) + if cc in ("", "000000000", "00000000"): + return None + if len(cc) < 6 or len(cc) > 9: + return None + return cc + + ncusip = norm_cusip(cusip) + has_metrics = (shares is not None) or (value is not None) or (pct is not None) + # Basic noise filtering: require name and at least value or pct + if name and (value is not None or pct is not None) and (ncusip or has_metrics): + local_holdings.append({ + "name": name, + "cusip": ncusip, + "shares": shares, + "value": value, + "percentage": pct, + "ticker": None, + }) + # If this scope yields plausible count, use it + if 80 <= len(local_holdings) <= 200: + # Choose scope closest to target_count + score = -abs(len(local_holdings) - target_count) + if score > best_scope_score: + best_scope_score = score + best_scope_result = local_holdings + # Else accumulate and try next scope + holdings.extend(local_holdings) + parsed_any = True + except Exception: + continue + + if best_scope_result is not None: + return best_scope_result + return holdings if parsed_any else [] + + # Synchronous parser to offload CPU-bound parsing to a background thread + def _parse_holdings_from_xml_sync( + self, + xml_text: str, + *, + filter_series_id: Optional[str] = None, + filter_class_id: Optional[str] = None, + filter_series_tokens: Optional[List[str]] = None, + ) -> List[Dict]: + def norm_cusip(c: Optional[str]) -> Optional[str]: + if not c: + return None + cc = "".join(ch for ch in c.upper() if ch.isalnum()) + if cc in ("", "000000000", "00000000"): + return None + if len(cc) < 6 or len(cc) > 9: + return None + return cc + + def extract_series_slices(xml: str, series_id: str) -> List[str]: + sid = re.escape(series_id) + pattern = re.compile(rf"(<seriesId>\s*{sid}\s*</seriesId>[\s\S]*?)(?=<seriesId>|</edgarSubmission>|\Z)", re.IGNORECASE) + return [m.group(1) for m in pattern.finditer(xml)] + + soup = BeautifulSoup(xml_text, "xml") + investment_tags = [ + "fundReportedHolding", + "invstOrSec", + "investmentOrSec", + "investment", + "holding", + "security", + ] + target_sid = (filter_series_id or "").strip() + target_cid = (filter_class_id or "").strip() + tokens = [t.lower() for t in (filter_series_tokens or []) if len(t) >= 3] + + def find_series_scopes() -> List: + scopes: List = [] + if target_sid: + for sid_node in soup.find_all("seriesId"): + try: + if sid_node.get_text(strip=True) != target_sid: + continue + ancestor = sid_node + for _ in range(30): + ancestor = ancestor.parent + if ancestor is None: + break + if any(ancestor.find(tag) for tag in investment_tags): + scopes.append(ancestor) + break + except Exception: + continue + if target_cid: + for cid_node in soup.find_all("classId"): + try: + if cid_node.get_text(strip=True) != target_cid: + continue + ancestor = cid_node + for _ in range(30): + ancestor = ancestor.parent + if ancestor is None: + break + if any(ancestor.find(tag) for tag in investment_tags): + scopes.append(ancestor) + break + except Exception: + continue + if tokens and not scopes: + for sn in soup.find_all("seriesName"): + try: + s = sn.get_text(strip=True).lower() + if not all(tok in s for tok in tokens): + continue + ancestor = sn + for _ in range(30): + ancestor = ancestor.parent + if ancestor is None: + break + if any(ancestor.find(tag) for tag in investment_tags): + scopes.append(ancestor) + break + except Exception: + continue + if not scopes: + scopes = [soup] + return scopes + + # If series id given, try slicing for speed/accuracy + if target_sid: + slices = extract_series_slices(xml_text, target_sid) + if slices: + best: Optional[List[Dict]] = None + best_score = -10**9 + for sl in slices: + res = self._parse_holdings_from_xml_sync( + sl, + filter_series_id=None, + filter_class_id=target_cid or None, + filter_series_tokens=filter_series_tokens, + ) + cnt = len(res) + score = -abs(cnt - 129) + if 50 <= cnt <= 400: + score += 10 + if cnt and score > best_score: + best_score = score + best = res + if best is not None: + return best + + scopes = find_series_scopes() + parsed_any = False + best_scope_result: Optional[List[Dict]] = None + best_scope_score: int = -10**9 + target_count = 129 + holdings: List[Dict] = [] + for scope in scopes: + elems: List = [] + frh = scope.find_all("fundReportedHolding") + if frh: + elems = frh + else: + for tag in investment_tags: + items = scope.find_all(tag) + if items: + elems.extend(items) + if not elems: + continue + try: + local: List[Dict] = [] + for inv in elems: + inv_attrs = {k.lower(): str(v) for k, v in (inv.attrs or {}).items()} + if target_sid and any(("series" in k and target_sid not in v) for k, v in inv_attrs.items()): + continue + if target_cid and any(("class" in k and target_cid not in v) for k, v in inv_attrs.items()): + continue + name = None + for nm_tag in ["name", "issuerName", "secName", "title", "Name"]: + el = inv.find(nm_tag) + if el and el.get_text(strip=True): + name = el.get_text(strip=True) + break + cusip = None + for ctag in ["cusip", "cusip9", "CUSIP"]: + el = inv.find(ctag) + if el and el.get_text(strip=True): + cusip = el.get_text(strip=True) + break + shares = None + for stag in ["shares", "balance", "Shares"]: + el = inv.find(stag) + if el and el.get_text(strip=True): + try: + shares = float(el.get_text(strip=True).replace(",", "")) + except Exception: + pass + break + value = None + for vtag in ["valUSD", "marketValue", "value", "MarketValue"]: + el = inv.find(vtag) + if el and el.get_text(strip=True): + try: + value = float(el.get_text(strip=True).replace(",", "")) + except Exception: + pass + break + pct = None + for ptag in ["pctVal", "PercentageOfNetAssets", "percentage"]: + el = inv.find(ptag) + if el and el.get_text(strip=True): + try: + pct = float(el.get_text(strip=True).replace("%", "").replace(",", "")) + except Exception: + pass + break + ncusip = norm_cusip(cusip) + has_metrics = (shares is not None) or (value is not None) or (pct is not None) + if name and (value is not None or pct is not None) and (ncusip or has_metrics): + local.append({ + "name": name, + "cusip": ncusip, + "shares": shares, + "value": value, + "percentage": pct, + "ticker": None, + }) + if 80 <= len(local) <= 200: + score = -abs(len(local) - target_count) + if score > best_scope_score: + best_scope_score = score + best_scope_result = local + holdings.extend(local) + parsed_any = True + except Exception: + continue + if best_scope_result is not None: + return best_scope_result + return holdings if parsed_any else [] + + async def _map_cusip_to_ticker(self, db: AsyncSession, holdings: List[Dict]) -> None: + """Annotate each holding with 'ticker' when CUSIP mapping exists.""" + if not holdings: + return + # Build set of cusips + def normalize(c: str) -> str: + return "".join(ch for ch in c.upper() if ch.isalnum()) + cusips = {normalize(h.get("cusip")) for h in holdings if h.get("cusip")} + if not cusips: + return + # Query in batches + mapping: Dict[str, str] = {} + for batch_start in range(0, len(cusips), 500): + batch = list(cusips)[batch_start: batch_start + 500] + result = await db.execute(select(CusipMap.cusip, CusipMap.symbol).where(CusipMap.cusip.in_(batch))) + for c, sym in result.all(): + mapping[normalize(c)] = sym + for h in holdings: + c = h.get("cusip") + if not c: + continue + nc = normalize(c) + if nc in mapping: + h["ticker"] = mapping[nc] + + def _top_coverage(self, holdings: List[Dict], target: float = 0.5) -> List[Dict]: + """Return minimal prefix of holdings that covers at least target (e.g., 0.5 for 50%) by percentage or value. + If percentage available, use it; else use value (descending). + """ + if not holdings: + return [] + # Prefer percentage if present + if any(h.get("percentage") for h in holdings): + sorted_h = sorted(holdings, key=lambda x: (x.get("percentage") or 0.0), reverse=True) + total = sum((h.get("percentage") or 0.0) for h in sorted_h) + # If percentages are already in 0-100 scale, normalize + scale = 100.0 if total > 1.0 else 1.0 + acc = 0.0 + res: List[Dict] = [] + for h in sorted_h: + res.append(h) + acc += (h.get("percentage") or 0.0) / scale + if acc >= target: + break + return res + # Fallback: use value + sorted_h = sorted(holdings, key=lambda x: (x.get("value") or 0.0), reverse=True) + total_val = sum((h.get("value") or 0.0) for h in sorted_h) + if total_val <= 0: + return sorted_h[: max(1, int(len(sorted_h) * target))] + acc = 0.0 + res: List[Dict] = [] + for h in sorted_h: + res.append(h) + acc += (h.get("value") or 0.0) / total_val + if acc >= target: + break + return res + + def _top_n(self, holdings: List[Dict], n: int) -> List[Dict]: + """Return top N holdings by percentage if available, otherwise by value.""" + if not holdings or n <= 0: + return [] + # Prefer percentage if present + if any(h.get("percentage") for h in holdings): + sorted_h = sorted(holdings, key=lambda x: (x.get("percentage") or 0.0), reverse=True) + else: + sorted_h = sorted(holdings, key=lambda x: (x.get("value") or 0.0), reverse=True) + return sorted_h[:n] + + async def get_holdings( + self, + db: AsyncSession, + ticker: str, + as_of_date: Optional[datetime], + top_n: Optional[int] = None, + top_percentage: Optional[float] = None, + ) -> Dict: + # Manual inception date short-circuit + tkr_upper = ticker.upper() + try: + start_str = (settings.ETF_START_DATES or {}).get(tkr_upper) + except Exception: + start_str = None + if as_of_date and start_str: + start_dt_date = None + try: + # Try ISO parse + start_dt_date = datetime.fromisoformat(start_str).date() + except Exception: + try: + start_dt_date = datetime.strptime(start_str, "%Y-%m-%d").date() + except Exception: + start_dt_date = None + if start_dt_date and as_of_date.date() < start_dt_date: + return { + "success": False, + "error": f"ETF {tkr_upper} did not exist on {as_of_date.date().isoformat()}", + "availability": { + "exists_for_date": False, + "earliest_available": start_dt_date.isoformat(), + "available_date_range": {"start": start_dt_date.isoformat(), "end": "present"}, + }, + } + + # Serve from DB snapshot cache if available + try: + cached = await self._load_snapshot_holdings(db, ticker=tkr_upper, as_of_date=as_of_date) + except Exception: + cached = None + if cached: + snap = cached["snapshot"] + holdings_cache = cached["holdings"] + filtered = holdings_cache + if top_n is not None and top_n > 0: + filtered = self._top_n(holdings_cache, int(top_n)) + elif top_percentage is not None and top_percentage > 0: + target = float(top_percentage) + if target > 1.0: + target = target / 100.0 + target = max(1e-9, min(1.0, target)) + filtered = self._top_coverage(holdings_cache, target) + return { + "success": True, + "ticker": tkr_upper, + "as_of_date": snap.snapshot_date.date().isoformat(), + "cik": snap.cik, + "filing": {"accession": snap.filing_accession, "xml_url": snap.xml_url, "snapshot_id": str(snap.id)}, + "holdings": filtered, + "holdings_count": len(filtered), + } + + # If configured, try ETF-Scraper first for selected tickers (e.g., MTUM) + if tkr_upper in set(settings.ETF_SCRAPER_TICKERS or []): + if _HAS_ETF_SCRAPER: + try: + scraper = ETFScraper() + def _query(date_obj: Optional[datetime]): + ds = date_obj.date().isoformat() if date_obj else None + return scraper.query_holdings(tkr_upper, ds), (date_obj.date().isoformat() if date_obj else None) + loop = asyncio.get_event_loop() + # Try exact date first (if provided), else latest + if as_of_date: + df, found_date = await loop.run_in_executor(None, lambda: _query(as_of_date)) + else: + df, found_date = await loop.run_in_executor(None, lambda: _query(None)) + # If empty for exact date, try previous few trading days + if (df is None or len(df) == 0) and as_of_date: + from datetime import timedelta as _td + for k in range(1, 8): + cand = as_of_date - _td(days=k) + df, found_date = await loop.run_in_executor(None, lambda c=cand: _query(c)) + if df is not None and len(df) > 0: + break + # If still empty, try month-end fallbacks up to 12 months (backward) + if (df is None or len(df) == 0) and as_of_date: + import calendar as _cal + cur = as_of_date + for m in range(0, 12): + y = cur.year + mo = cur.month + last_day = _cal.monthrange(y, mo)[1] + cand_date = datetime(y, mo, last_day, tzinfo=timezone.utc) + # adjust weekend to previous weekday + while cand_date.weekday() >= 5: + cand_date = cand_date - timedelta(days=1) + df, found_date = await loop.run_in_executor(None, lambda c=cand_date: _query(c)) + if df is not None and len(df) > 0: + break + # move to previous month + if mo == 1: + y -= 1 + mo = 12 + else: + mo -= 1 + cur = datetime(y, mo, 1, tzinfo=timezone.utc) + # If still empty, probe forward month-ends up to 120 months to find earliest available + earliest_found = None + if (df is None or len(df) == 0) and as_of_date: + import calendar as _cal + cur = as_of_date + for m in range(0, 120): + y = cur.year + mo = cur.month + last_day = _cal.monthrange(y, mo)[1] + cand_date = datetime(y, mo, last_day, tzinfo=timezone.utc) + while cand_date.weekday() >= 5: + cand_date = cand_date - timedelta(days=1) + tmp_df, tmp_date = await loop.run_in_executor(None, lambda c=cand_date: _query(c)) + if tmp_df is not None and len(tmp_df) > 0: + earliest_found = tmp_date + break + # next month + if mo == 12: + y += 1 + mo = 1 + else: + mo += 1 + cur = datetime(y, mo, 1, tzinfo=timezone.utc) + holdings: List[Dict] = [] + if df is not None and len(df) > 0: + for _, row in df.iterrows(): + name = row.get("name") or row.get("issuer") or row.get("security") or row.get("Security Name") + cusip = row.get("cusip") or row.get("CUSIP") + # Normalize CUSIP to alphanumeric + if cusip is not None: + cusip = "".join(ch for ch in str(cusip).upper() if ch.isalnum()) or None + shares = row.get("shares") or row.get("Shares") + value = row.get("market_value") or row.get("Market Value") or row.get("value") + pct = row.get("weight") or row.get("Weight") or row.get("percentage") + # Parse percentage if string like '5.12%' + if isinstance(pct, str): + pct = pct.strip().replace("%", "") + try: + pct = float(pct) + except Exception: + pct = None + # Prefer provider ticker/symbol if present + row_ticker = ( + row.get("ticker") + or row.get("Ticker") + or row.get("symbol") + or row.get("Symbol") + ) + if isinstance(row_ticker, str): + row_ticker = row_ticker.strip().upper() or None + holdings.append({ + "name": name, + "cusip": (str(cusip).strip() if cusip else None), + "shares": float(shares) if shares is not None else None, + "value": float(value) if value is not None else None, + "percentage": float(pct) if pct is not None else None, + "ticker": row_ticker, + }) + await self._map_cusip_to_ticker(db, holdings) + # Persist full snapshot then filter for response + # Determine snapshot date for persistence + try: + snap_dt = None + if isinstance(found_date, str): + snap_dt = datetime.strptime(found_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) + else: + snap_dt = as_of_date or datetime.now(timezone.utc) + except Exception: + snap_dt = as_of_date or datetime.now(timezone.utc) + _ = await self._persist_snapshot_and_holdings( + db, + ticker=tkr_upper, + snapshot_date=snap_dt, + source="SCRAPER", + cik=None, + filing_accession=None, + xml_url=None, + metadata={"provider": "ETF-Scraper"}, + holdings=holdings, + ) + filtered = holdings + if top_n is not None and top_n > 0: + filtered = self._top_n(holdings, int(top_n)) + elif top_percentage is not None and top_percentage > 0: + target = float(top_percentage) + if target > 1.0: + target = target / 100.0 + target = max(1e-9, min(1.0, target)) + filtered = self._top_coverage(holdings, target) + return { + "success": True, + "ticker": tkr_upper, + "as_of_date": found_date or ((as_of_date.date().isoformat() if as_of_date else None) or None), + "cik": None, + "filing": None, + "holdings": filtered, + "holdings_count": len(filtered), + } + # If ETF-Scraper had no data for requested historical date, return availability hint instead of CIK error + if as_of_date: + return { + "success": False, + "error": f"ETF {tkr_upper} did not exist on {as_of_date.date().isoformat()}", + "availability": { + "exists_for_date": False, + "earliest_available": earliest_found, + "available_date_range": {"start": earliest_found, "end": "present"} + }, + } + except Exception: + # Fall back to SEC logic + pass + # Resolve CIK and normalize + # Set per-request deadline so we don't hang beyond ~45s inside the server + self._deadline = _time.monotonic() + 45.0 + try: + rec = await self._get_cik_record(db, ticker) + cik = rec.get("cik") if rec else None + if not cik: + # If CIK missing, and we have ETF-Scraper, try to infer earliest availability for the requested date + if _HAS_ETF_SCRAPER and as_of_date: + try: + scraper = ETFScraper() + def _query(date_obj: Optional[datetime]): + ds = date_obj.date().isoformat() if date_obj else None + return scraper.query_holdings(tkr_upper, ds), (date_obj.date().isoformat() if date_obj else None) + loop = asyncio.get_event_loop() + df = None + found_date = None + earliest_found = None + df, found_date = await loop.run_in_executor(None, lambda: _query(as_of_date)) + if (df is None or len(df) == 0): + # scan forward month-ends up to 120 months for earliest available + import calendar as _cal + cur = as_of_date + for m in range(0, 120): + y = cur.year + mo = cur.month + last_day = _cal.monthrange(y, mo)[1] + cand_date = datetime(y, mo, last_day, tzinfo=timezone.utc) + while cand_date.weekday() >= 5: + cand_date = cand_date - timedelta(days=1) + tmp_df, tmp_date = await loop.run_in_executor(None, lambda c=cand_date: _query(c)) + if tmp_df is not None and len(tmp_df) > 0: + earliest_found = tmp_date + break + # next month + if mo == 12: + y += 1 + mo = 1 + else: + mo += 1 + cur = datetime(y, mo, 1, tzinfo=timezone.utc) + return { + "success": False, + "error": f"ETF {tkr_upper} did not exist on {as_of_date.date().isoformat()}", + "availability": { + "exists_for_date": False, + "earliest_available": earliest_found, + "available_date_range": {"start": earliest_found, "end": "present"} + }, + } + except Exception: + # Even if scraper fails, for configured tickers and historical dates, return pre-launch style error + return { + "success": False, + "error": f"ETF {tkr_upper} did not exist on {as_of_date.date().isoformat()}", + "availability": { + "exists_for_date": False, + "earliest_available": None, + "available_date_range": {"start": None, "end": "present"} + }, + } + return { + "success": False, + "error": f"No CIK mapping for ETF ticker {ticker}", + "availability": None, + } + cik_digits = "".join(ch for ch in str(cik) if ch.isdigit()) + if not cik_digits: + return {"success": False, "error": f"Invalid CIK stored for {ticker}", "availability": None} + + # Find best filing (closest <= date). If target_date None -> most recent + filing = await self._find_best_filing_and_xml( + cik_digits, + as_of_date, + ticker=ticker, + fund_name=(rec.get("name") if rec else None), + series_id=(rec.get("series_id") if rec else None), + class_id=(rec.get("class_id") if rec else None), + ) + if not filing: + return {"success": False, "error": "No NPORT-P filings found", "availability": None} + + filing_date, accession, xml_url = filing + filing_dt = datetime.strptime(filing_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) + + # If user provided a date earlier than earliest filing, treat as pre-launch error + if as_of_date and filing_dt > as_of_date: + # Provide valid start date + return { + "success": False, + "error": f"ETF {ticker.upper()} did not exist on {as_of_date.date().isoformat()}", + "availability": { + "exists_for_date": False, + "earliest_available": filing_date, + "available_date_range": {"start": filing_date, "end": "present"}, + }, + } + + # Download XML and parse + xml_text = await self._fetch_text(xml_url) + # Basic parsing only (series/class logic disabled per request) + series_tokens: Optional[List[str]] = None + if rec and rec.get("name"): + series_tokens = [tok for tok in rec["name"].replace("(", " ").replace(")", " ").replace(",", " ").split() if len(tok) >= 3] + holdings = await self._parse_holdings_from_xml( + xml_text, + filter_series_id=None, + filter_class_id=None, + filter_series_tokens=series_tokens, + ) + # If suspiciously low count, try alternative candidates from index (still basic parsing only) + expected_min = 50 + expected_max = 700 + if len(holdings) < expected_min or len(holdings) > expected_max: + # Expand search scope and retry + filing2 = await self._find_best_filing_and_xml( + cik_digits, + as_of_date, + ticker=ticker, + fund_name=(rec.get("name") if rec else None), + series_id=None, + class_id=None, + scan_limit=60, + ) + if filing2: + filing_date2, accession2, xml_url2 = filing2 + try: + xml_text2 = await self._fetch_text(xml_url2) + h2 = await self._parse_holdings_from_xml( + xml_text2, + filter_series_id=None, + filter_class_id=None, + filter_series_tokens=series_tokens, + ) + if not h2: + h2 = await self._parse_holdings_from_xml( + xml_text2, + filter_series_id=None, + filter_class_id=None, + filter_series_tokens=series_tokens, + ) + if not h2: + h2 = await self._parse_holdings_from_xml(xml_text2) + if len(h2) >= expected_min and len(h2) <= expected_max: + filing_date, accession, xml_url, holdings = filing_date2, accession2, xml_url2, h2 + except Exception: + pass + + if len(holdings) < expected_min: + candidates = await self._list_candidate_docs( + cik_digits, + accession, + ticker=ticker, + fund_name=(rec.get("name") if rec else None), + series_id=None, + class_id=None, + ) + for alt in candidates: + if alt == xml_url: + continue + try: + xml_text_alt = await self._fetch_text(alt) + h_alt = await self._parse_holdings_from_xml( + xml_text_alt, + filter_series_id=None, + filter_class_id=None, + filter_series_tokens=series_tokens, + ) + if not h_alt: + h_alt = await self._parse_holdings_from_xml( + xml_text_alt, + filter_series_id=None, + filter_class_id=None, + filter_series_tokens=series_tokens, + ) + if not h_alt: + h_alt = await self._parse_holdings_from_xml(xml_text_alt) + if len(h_alt) > len(holdings): + xml_url = alt + holdings = h_alt + break + except Exception: + continue + await self._map_cusip_to_ticker(db, holdings) + + # Persist full snapshot before filtering (SEC source) + snapshot_id = await self._persist_snapshot_and_holdings( + db, + ticker=ticker.upper(), + snapshot_date=filing_dt, + source="SEC", + cik=cik_digits, + filing_accession=accession, + xml_url=xml_url, + metadata={"filing_date": filing_date}, + holdings=holdings, + ) + + # Apply top filters for response only + filtered = holdings + if top_n is not None and top_n > 0: + filtered = self._top_n(holdings, int(top_n)) + elif top_percentage is not None and top_percentage > 0: + target = float(top_percentage) + # Normalize: if given as 0-100, convert to 0-1 + if target > 1.0: + target = target / 100.0 + # Clamp between (0,1] + target = max(1e-9, min(1.0, target)) + filtered = self._top_coverage(holdings, target) + + return { + "success": True, + "ticker": ticker.upper(), + "as_of_date": filing_date, + "cik": cik_digits, + "filing": {"accession": accession, "xml_url": xml_url, "snapshot_id": snapshot_id}, + "holdings": filtered, + "holdings_count": len(filtered), + } + finally: + # Clear deadline for next request + self._deadline = None + + +etf_holdings_fetcher = ETFHoldingsFetcher() + + diff --git a/app/services/etf_loader_service.py b/app/services/etf_loader_service.py new file mode 100644 index 0000000..dfd286c --- /dev/null +++ b/app/services/etf_loader_service.py @@ -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() + + diff --git a/app/services/financial_service.py b/app/services/financial_service.py new file mode 100644 index 0000000..795999a --- /dev/null +++ b/app/services/financial_service.py @@ -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 \ No newline at end of file diff --git a/app/services/fred_proxy_service.py b/app/services/fred_proxy_service.py new file mode 100644 index 0000000..c0b18fc --- /dev/null +++ b/app/services/fred_proxy_service.py @@ -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() \ No newline at end of file diff --git a/app/services/fred_service.py b/app/services/fred_service.py new file mode 100644 index 0000000..befb3b8 --- /dev/null +++ b/app/services/fred_service.py @@ -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() \ No newline at end of file diff --git a/app/services/news_social_service.py b/app/services/news_social_service.py new file mode 100644 index 0000000..58edf5f --- /dev/null +++ b/app/services/news_social_service.py @@ -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"] \ No newline at end of file diff --git a/app/services/price_data_service.py b/app/services/price_data_service.py new file mode 100644 index 0000000..fb3c1ac --- /dev/null +++ b/app/services/price_data_service.py @@ -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 \ No newline at end of file diff --git a/app/services/real_sec_financial_service.py b/app/services/real_sec_financial_service.py new file mode 100644 index 0000000..fde67fe --- /dev/null +++ b/app/services/real_sec_financial_service.py @@ -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 \ No newline at end of file diff --git a/app/services/sec_data_service.py b/app/services/sec_data_service.py new file mode 100644 index 0000000..d4611b0 --- /dev/null +++ b/app/services/sec_data_service.py @@ -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 + ) diff --git a/app/services/sec_edgar_service.py b/app/services/sec_edgar_service.py new file mode 100644 index 0000000..c87753c --- /dev/null +++ b/app/services/sec_edgar_service.py @@ -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' + } \ No newline at end of file diff --git a/app/services/yahoo_52week_gainers_service.py b/app/services/yahoo_52week_gainers_service.py new file mode 100644 index 0000000..a0572dc --- /dev/null +++ b/app/services/yahoo_52week_gainers_service.py @@ -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() \ No newline at end of file diff --git a/app/services/yahoo_most_active_service.py b/app/services/yahoo_most_active_service.py new file mode 100644 index 0000000..b3f2510 --- /dev/null +++ b/app/services/yahoo_most_active_service.py @@ -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() \ No newline at end of file diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..7c77226 --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1,3 @@ +""" +Utility functions for Stock Oracle +""" \ No newline at end of file diff --git a/app/utils/cache.py b/app/utils/cache.py new file mode 100644 index 0000000..cb971d1 --- /dev/null +++ b/app/utils/cache.py @@ -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 + + + + diff --git a/app/utils/date_utils.py b/app/utils/date_utils.py new file mode 100644 index 0000000..ec7b6cc --- /dev/null +++ b/app/utils/date_utils.py @@ -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") \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5e3a424 --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/docs/ETF_API.md b/docs/ETF_API.md new file mode 100644 index 0000000..efc32c3 --- /dev/null +++ b/docs/ETF_API.md @@ -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 \ No newline at end of file diff --git a/docs/PYTHON_CLIENT.md b/docs/PYTHON_CLIENT.md new file mode 100644 index 0000000..3adccb2 --- /dev/null +++ b/docs/PYTHON_CLIENT.md @@ -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 \ No newline at end of file diff --git a/examples/client_usage.py b/examples/client_usage.py new file mode 100644 index 0000000..414d13b --- /dev/null +++ b/examples/client_usage.py @@ -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() \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..c8cc846 --- /dev/null +++ b/frontend/.dockerignore @@ -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 \ No newline at end of file diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..cdbf46f --- /dev/null +++ b/frontend/.env.example @@ -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 \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..94edf35 --- /dev/null +++ b/frontend/Dockerfile @@ -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"] \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..2ba635b --- /dev/null +++ b/frontend/README.md @@ -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 ํŒŒ์ผ์„ ์ฐธ์กฐํ•˜์„ธ์š”. \ No newline at end of file diff --git a/frontend/components/ClientLayout.tsx b/frontend/components/ClientLayout.tsx new file mode 100644 index 0000000..a96d482 --- /dev/null +++ b/frontend/components/ClientLayout.tsx @@ -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} + + + + {/* Favicon and Icons */} + + + + + {/* Web App Manifest */} + + + {/* Theme Colors */} + + + + {/* Open Graph */} + + + + + + {/* Twitter Card */} + + + + + + +
+ {/* ์‚ฌ์ด๋“œ๋ฐ” */} +
+
+
+ +

Stock Oracle

+
+
+ + + + {/* ํ•˜๋‹จ ์ •๋ณด */} +
+
+

Stock Oracle v1.0.0

+

Real-time Financial Data

+
+
+
+ + {/* ๋ฉ”์ธ ์ฝ˜ํ…์ธ  */} +
+ {/* ํ—ค๋” */} +
+
+
+

+ {navigation.find(item => item.current)?.name || 'Stock Oracle'} +

+ +
+ {/* API ์ƒํƒœ ํ‘œ์‹œ */} +
+
+ API ์—ฐ๊ฒฐ๋จ +
+ + {/* ํ˜„์žฌ ์‹œ๊ฐ„ */} +
+ {mounted ? currentTime : '--:--:--'} +
+
+
+
+
+ + {/* ํŽ˜์ด์ง€ ์ฝ˜ํ…์ธ  */} +
+
+ {children} +
+
+
+
+ + ); +}; + +export default ClientLayout; \ No newline at end of file diff --git a/frontend/components/DatabaseStats.tsx b/frontend/components/DatabaseStats.tsx new file mode 100644 index 0000000..ee2ed1c --- /dev/null +++ b/frontend/components/DatabaseStats.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(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 ( +
+
+
+

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํ†ต๊ณ„๋ฅผ ๋กœ๋”ฉ ์ค‘...

+
+
+ ); + } + + if (error) { + return ( +
+
+
+ +

{error}

+
+ +
+
+ ); + } + + if (!stats) return null; + + return ( +
+ {/* ํ—ค๋” */} +
+

+ + ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํ˜„ํ™ฉ +

+ +
+ {lastUpdated && ( + + ๋งˆ์ง€๋ง‰ ์—…๋ฐ์ดํŠธ: {lastUpdated.toLocaleString('ko-KR')} + + )} + +
+
+ + {/* ์š”์•ฝ ์นด๋“œ๋“ค */} +
+ {/* ์ด ํšŒ์‚ฌ ์ˆ˜ */} +
+
+
+ +
+
+

๋“ฑ๋ก๋œ ํšŒ์‚ฌ

+

{formatNumber(stats.companies.total)}

+
+
+
+

์žฌ๋ฌด ๋ฐ์ดํ„ฐ: {stats.companies.with_financial_data}๊ฐœ

+

์ฃผ๊ฐ€ ๋ฐ์ดํ„ฐ: {stats.companies.with_price_data}๊ฐœ

+
+
+ + {/* ์žฌ๋ฌด ๋ฐ์ดํ„ฐ */} +
+
+
+ +
+
+

์žฌ๋ฌด ๋ฐ์ดํ„ฐ

+

{formatNumber(stats.financial_data.total_records)}

+
+
+
+

์‹ค์ œ: {formatNumber(stats.financial_data.real_data)}

+

์ถ”์ •: {formatNumber(stats.financial_data.estimated_data)}

+
+
+ + {/* ์ฃผ๊ฐ€ ๋ฐ์ดํ„ฐ */} +
+
+
+ +
+
+

์ฃผ๊ฐ€ ๋ฐ์ดํ„ฐ

+

{formatNumber(stats.price_data.total_records)}

+
+
+
+

์ข…๋ชฉ ์ˆ˜: {stats.price_data.tickers.length}๊ฐœ

+
+
+ + {/* ๊ณ„์‚ฐ๋œ ์ง€ํ‘œ */} +
+
+
+ +
+
+

๊ณ„์‚ฐ๋œ ์ง€ํ‘œ

+

{formatNumber(stats.calculated_metrics.total_records)}

+
+
+
+
+ + {/* ์ƒ์„ธ ์ •๋ณด */} +
+ {/* ์žฌ๋ฌด ๋ฐ์ดํ„ฐ ์ƒ์„ธ */} +
+

์žฌ๋ฌด ๋ฐ์ดํ„ฐ ์ƒ์„ธ

+ +
+
+
+ ์‹ค์ œ ๋ฐ์ดํ„ฐ + + {stats.financial_data.real_data} ({((stats.financial_data.real_data / stats.financial_data.total_records) * 100).toFixed(1)}%) + +
+
+
+
+
+ +
+
+ ์ถ”์ • ๋ฐ์ดํ„ฐ + + {stats.financial_data.estimated_data} ({((stats.financial_data.estimated_data / stats.financial_data.total_records) * 100).toFixed(1)}%) + +
+
+
+
+
+ +
+

๋ฐ์ดํ„ฐ ๊ธฐ๊ฐ„

+

+ {new Date(stats.financial_data.date_range.earliest).toLocaleDateString('ko-KR')} ~ {new Date(stats.financial_data.date_range.latest).toLocaleDateString('ko-KR')} +

+
+
+
+ + {/* ๋ฐ์ดํ„ฐ ์†Œ์Šค๋ณ„ ๋ถ„ํฌ */} +
+

๋ฐ์ดํ„ฐ ์†Œ์Šค๋ณ„ ๋ถ„ํฌ

+ +
+ {Object.entries(stats.financial_data.by_source).map(([source, count]) => ( +
+
+
+ {source} +
+
+ {formatNumber(count)} + + ({((count / stats.financial_data.total_records) * 100).toFixed(1)}%) + +
+
+ ))} +
+
+
+ + {/* ์ฃผ๊ฐ€ ๋ฐ์ดํ„ฐ ์ข…๋ชฉ ๋ชฉ๋ก */} +
+

์ฃผ๊ฐ€ ๋ฐ์ดํ„ฐ ๋ณด์œ  ์ข…๋ชฉ

+ +
+ {stats.price_data.tickers.map((ticker) => ( +
+ {ticker} +
+ ))} +
+ +
+

์ฃผ๊ฐ€ ๋ฐ์ดํ„ฐ ๊ธฐ๊ฐ„: {new Date(stats.price_data.date_range.earliest).toLocaleDateString('ko-KR')} ~ {new Date(stats.price_data.date_range.latest).toLocaleDateString('ko-KR')}

+
+
+
+ ); +}; + +export default DatabaseStats; \ No newline at end of file diff --git a/frontend/components/ErrorLogViewer.tsx b/frontend/components/ErrorLogViewer.tsx new file mode 100644 index 0000000..4b12963 --- /dev/null +++ b/frontend/components/ErrorLogViewer.tsx @@ -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; + errors_by_status_code: Record; + errors_by_endpoint: Record; + 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([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedLog, setSelectedLog] = useState(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 = {}; + 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 = {}; + 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 = { + '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 ( +
+
+
+

์—๋Ÿฌ ๋กœ๊ทธ ๊ด€๋ฆฌ

+
+
+ + +
+
+ + {/* Statistics */} + {stats && ( +
+
+
+
+

Total Errors

+

{stats.total_errors}

+
+ +
+
+ +
+
+
+

Resolved

+

{stats.resolved_errors}

+
+ +
+
+ +
+
+
+

Unresolved

+

{stats.unresolved_errors}

+
+ +
+
+ +
+
+
+

Resolution Rate

+

{stats.resolution_rate.toFixed(1)}%

+
+ +
+
+
+ )} + + {/* Filters */} +
+
+ +

Filters

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + 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" + /> +
+ +
+ + +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+
+
+ + {/* Error Logs Table */} +
+
+ + + + + + + + + + + + + + {loading ? ( + + + + ) : errorLogs.length === 0 ? ( + + + + ) : ( + errorLogs.map((log) => ( + + + + + + + + + + )) + )} + +
+ Time + + Endpoint + + Error Type + + Status + + Message + + Resolution + + Actions +
+ Loading error logs... +
+ No error logs found +
+ {formatDate(log.created_at)} + +
+ {log.method} +
+ {log.endpoint} +
+
+ + {log.error_type} + + + + {log.status_code} + + + {log.error_message} + + {log.is_resolved ? ( + + + Resolved + + ) : ( + + + Open + + )} + + + +
+
+
+ + {/* Pagination */} +
+
+ Showing {errorLogs.length} errors +
+
+ + + {filters.page} + + +
+
+ + {/* Error Details Modal */} + {showDetails && selectedLog && ( +
+
+
+
+

Error Details

+ +
+ +
+
+
+ +

{selectedLog.request_id}

+
+
+ +

{formatDate(selectedLog.created_at)}

+
+
+ +
+
+ +

+ {selectedLog.method} {selectedLog.endpoint} +

+
+
+ + + {selectedLog.status_code} + +
+
+ +
+ +

{selectedLog.error_message}

+
+ + {selectedLog.error_detail && ( +
+ +
+                      {JSON.stringify(selectedLog.error_detail, null, 2)}
+                    
+
+ )} + + {selectedLog.request_body && ( +
+ +
+                      {JSON.stringify(selectedLog.request_body, null, 2)}
+                    
+
+ )} + + {selectedLog.stack_trace && ( +
+ +
+                      {selectedLog.stack_trace}
+                    
+
+ )} + +
+ + +
+
+
+
+
+ )} +
+ ); +}; + +export default ErrorLogViewer; \ No newline at end of file diff --git a/frontend/components/Layout.tsx b/frontend/components/Layout.tsx new file mode 100644 index 0000000..2a22431 --- /dev/null +++ b/frontend/components/Layout.tsx @@ -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: () => ( +
+
+
+

Loading Stock Oracle...

+
+
+ ), +}); + +export default ClientLayout; \ No newline at end of file diff --git a/frontend/components/LogsPageContent.tsx b/frontend/components/LogsPageContent.tsx new file mode 100644 index 0000000..656ab27 --- /dev/null +++ b/frontend/components/LogsPageContent.tsx @@ -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 ( + + + + ); +}; + +export default LogsPageContent; \ No newline at end of file diff --git a/frontend/components/NewsSocialDisplay.tsx b/frontend/components/NewsSocialDisplay.tsx new file mode 100644 index 0000000..f4f3772 --- /dev/null +++ b/frontend/components/NewsSocialDisplay.tsx @@ -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 = ({ ticker }) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState<'all' | 'news' | 'social'>('all'); + const [settings, setSettings] = useState({ + 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 }) => ( +
+
+
+ + {article.source} + {article.published_at && ( + <> + + {formatTimeAgo(article.published_at)} + + )} +
+
+ +

+ + {article.title} + +

+ + {article.summary && ( +

+ {article.summary} +

+ )} + +
+
+ {article.author && ( + <> + + {article.author} + + )} +
+ + ์ฝ๊ธฐ + + +
+
+ ); + + const SocialCard: React.FC<{ post: SocialPost }> = ({ post }) => ( +
+
+
+ + {post.platform} + {post.subreddit && ( + r/{post.subreddit} + )} + {post.published_at && ( + <> + + {formatTimeAgo(post.published_at)} + + )} +
+ {typeof post.score === 'number' && ( +
+ + {post.score} +
+ )} +
+ +

+ + {post.title} + +

+ + {post.content && post.content.length > 0 && ( +

+ {post.content} +

+ )} + +
+
+ + {post.author} + {typeof post.comments_count === 'number' && post.comments_count > 0 && ( + <> + + {post.comments_count} ๋Œ“๊ธ€ + + )} +
+ + ๋ณด๊ธฐ + + +
+
+ ); + + if (!ticker) { + return ( +
+ +

์ข…๋ชฉ์„ ์„ ํƒํ•˜๋ฉด ๊ด€๋ จ ๋‰ด์Šค์™€ ์†Œ์…œ ๋ฏธ๋””์–ด ๋ฐ์ดํ„ฐ๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

+
+ ); + } + + return ( +
+ {/* ํ—ค๋” ๋ฐ ์„ค์ • */} +
+
+

+ ๋‰ด์Šค & ์†Œ์…œ ๋ฏธ๋””์–ด ({ticker}) +

+ +
+ + {/* ์„ค์ • ์˜ต์…˜ */} +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {/* ํƒญ */} +
+ + + +
+
+ + {/* ๋กœ๋”ฉ ์ƒํƒœ */} + {loading && ( +
+ +

๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+ )} + + {/* ์˜ค๋ฅ˜ ์ƒํƒœ */} + {error && !loading && ( +
+

{error}

+
+ )} + + {/* ๋ฐ์ดํ„ฐ ํ‘œ์‹œ - ๋กœ๋”ฉ ์ค‘์ด ์•„๋‹ˆ๊ณ  ์—๋Ÿฌ๋„ ์—†์„ ๋•Œ๋งŒ */} + {!loading && !error && data && ( + <> + {/* ์š”์•ฝ ์ •๋ณด - ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์„ ๋•Œ๋งŒ ํ‘œ์‹œ */} + {data.summary.total_items > 0 && ( +
+
+
+
{data.news.total_articles}
+
๋‰ด์Šค ๊ธฐ์‚ฌ
+
+
+
{data.social_media.total_posts}
+
์†Œ์…œ ํฌ์ŠคํŠธ
+
+
+
{data.summary.total_items}
+
์ „์ฒด ํ•ญ๋ชฉ
+
+
+
{data.summary.time_range_days}
+
์กฐํšŒ ์ผ์ˆ˜
+
+
+
+ )} + + {/* ์ฝ˜ํ…์ธ  ํ‘œ์‹œ */} + {(activeTab === 'all' || activeTab === 'news') && data.news.articles.length > 0 && ( +
+ {activeTab === 'all' &&

๋‰ด์Šค ๊ธฐ์‚ฌ

} +
+ {data.news.articles.map((article, index) => ( + + ))} +
+
+ )} + + {(activeTab === 'all' || activeTab === 'social') && data.social_media.posts.length > 0 && ( +
+ {activeTab === 'all' &&

์†Œ์…œ ๋ฏธ๋””์–ด

} +
+ {data.social_media.posts.map((post, index) => ( + + ))} +
+
+ )} + + {/* ๋ฐ์ดํ„ฐ ์—†์Œ ๋ฉ”์‹œ์ง€ */} + {data.summary.total_items === 0 && ( +
+ +

{ticker}์— ๋Œ€ํ•œ ๋‰ด์Šค๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

+

๋‹ค๋ฅธ ๊ธฐ๊ฐ„์„ ์„ ํƒํ•˜๊ฑฐ๋‚˜ ๋‹ค๋ฅธ ์ข…๋ชฉ์„ ๊ฒ€์ƒ‰ํ•ด ๋ณด์„ธ์š”.

+
+ )} + + )} +
+ ); +}; + +export default NewsSocialDisplay; \ No newline at end of file diff --git a/frontend/components/RequestLogViewer.tsx b/frontend/components/RequestLogViewer.tsx new file mode 100644 index 0000000..7f5f066 --- /dev/null +++ b/frontend/components/RequestLogViewer.tsx @@ -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([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [showFilters, setShowFilters] = useState(false); + const [showStats, setShowStats] = useState(false); + const [expandedLog, setExpandedLog] = useState(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 = { + '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 ( +
+
+ {/* Header */} +
+
+
+

+ + ์š”์ฒญ ๋กœ๊ทธ +

+

+ API ์š”์ฒญ ๋‚ด์—ญ ๋ฐ ํ†ต๊ณ„๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค +

+
+
+ + + +
+
+
+ + {/* Statistics */} + {showStats && stats && ( +
+
+
+
+

์ด ์š”์ฒญ

+

{stats.total_requests.toLocaleString()}

+
+ +
+
+
+
+
+

์„ฑ๊ณต๋ฅ 

+

{stats.success_rate.toFixed(1)}%

+
+ +
+
+
+
+
+

ํ‰๊ท  ์‘๋‹ต์‹œ๊ฐ„

+

{formatResponseTime(stats.average_response_time_ms)}

+
+ +
+
+
+
+
+

์„œ๋ฒ„ ์—๋Ÿฌ

+

{stats.server_error_requests.toLocaleString()}

+
+ +
+
+
+ )} + + {/* Filters */} + {showFilters && ( +
+
+
+ + setFilters({ ...filters, start_date: e.target.value })} + className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setFilters({ ...filters, end_date: e.target.value })} + className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" + /> +
+
+ + +
+
+ + setFilters({ ...filters, status_code: e.target.value })} + className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setFilters({ ...filters, endpoint: e.target.value })} + className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" + /> +
+
+
+ )} + + {/* Logs Table */} +
+ {loading ? ( +
+ +

์š”์ฒญ ๋กœ๊ทธ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+ ) : ( +
+ + + + + + + + + + + + + + + {logs.map((log) => ( + + + + + + + + + + + + {expandedLog === log.id && ( + + + + )} + + ))} + +
+ ์‹œ๊ฐ„ + + ๋ฉ”์„œ๋“œ + + ์—”๋“œํฌ์ธํŠธ + + ์ƒํƒœ + + ์‘๋‹ต์‹œ๊ฐ„ + + ํฌ๊ธฐ + + IP + + ์ƒ์„ธ +
+ {log.created_at ? new Date(log.created_at).toLocaleString('ko-KR') : 'N/A'} + + + {log.method} + + + {log.endpoint} + + + {log.status_code} + + + {formatResponseTime(log.response_time_ms)} + + {formatSize(log.response_size)} + + {log.client_ip || 'N/A'} + + +
+
+
+

์š”์ฒญ ์ •๋ณด

+
+

Request ID: {log.request_id}

+

Full Path: {log.path}

+

User Agent: {log.user_agent || 'N/A'}

+ {log.query_params && Object.keys(log.query_params).length > 0 && ( +
+ Query Parameters: +
+                                        {JSON.stringify(log.query_params, null, 2)}
+                                      
+
+ )} + {log.request_body && ( +
+ Request Body: +
+                                        {JSON.stringify(log.request_body, null, 2)}
+                                      
+
+ )} +
+
+
+
+ {logs.length === 0 && ( +
+ +

์กฐ๊ฑด์— ๋งž๋Š” ์š”์ฒญ ๋กœ๊ทธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

+
+ )} +
+ )} +
+
+
+ ); +}; + +export default RequestLogViewer; \ No newline at end of file diff --git a/frontend/components/StockQueryFixed.tsx b/frontend/components/StockQueryFixed.tsx new file mode 100644 index 0000000..33c968f --- /dev/null +++ b/frontend/components/StockQueryFixed.tsx @@ -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(''); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [periodType, setPeriodType] = useState<'quarterly' | 'annual' | 'all'>('quarterly'); + const [includeMetrics, setIncludeMetrics] = useState(true); + const [forceRefresh, setForceRefresh] = useState(false); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+ {/* ๊ฒ€์ƒ‰ ํผ */} +
+

+ + ์ฃผ์‹ ๋ฐ์ดํ„ฐ ์กฐํšŒ +

+ +
+
+ {/* ์ข…๋ชฉ ์ฝ”๋“œ */} +
+ + 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 + /> +
+ + {/* ์‹œ์ž‘ ๋‚ ์งœ */} +
+ + 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 + /> +
+ + {/* ์ข…๋ฃŒ ๋‚ ์งœ */} +
+ + 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 + /> +
+ + {/* ๊ธฐ๊ฐ„ ํƒ€์ž… */} +
+ + +
+ + {/* ์˜ต์…˜๋“ค */} +
+ + + +
+
+ + {/* ๊ฒ€์ƒ‰ ๋ฒ„ํŠผ */} +
+ +
+
+
+ + {/* ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ */} + {error && ( +
+
+ +

{error}

+
+
+ )} + + {/* ๊ฒฐ๊ณผ ํ‘œ์‹œ */} + {data && ( +
+ {/* ํšŒ์‚ฌ ์ •๋ณด */} +
+

+ + ํšŒ์‚ฌ ์ •๋ณด +

+ +
+
+

ํšŒ์‚ฌ๋ช…

+

{data.company.name}

+
+
+

์ข…๋ชฉ ์ฝ”๋“œ

+

{data.company.ticker}

+
+
+

์„นํ„ฐ

+

{data.company.sector || 'N/A'}

+
+
+

์‚ฐ์—…

+

{data.company.industry || 'N/A'}

+
+
+
+ + {/* ์žฌ๋ฌด ๋ฐ์ดํ„ฐ */} +
+

+ + ์žฌ๋ฌด ๋ฐ์ดํ„ฐ ({data.financial_data.length}๊ฐœ ๊ธฐ๊ฐ„) +

+ +
+ + + + + + + + + + + + {data.financial_data.map((item, index) => ( + + + + + + + + ))} + +
๊ธฐ๊ฐ„๋งค์ถœ์ˆœ์ด์ต์ด์ž์‚ฐP/E ๋น„์œจ
+ {formatDate(item.period_date)} + + {formatCurrency(item.revenue)} + + {formatCurrency(item.net_income)} + + {formatCurrency(item.total_assets)} + + {item.pe_ratio?.toFixed(2) || 'N/A'} +
+
+
+
+ )} +
+ ); +}; + +export default StockQueryFixed; \ No newline at end of file diff --git a/frontend/components/UnifiedLogViewer.tsx b/frontend/components/UnifiedLogViewer.tsx new file mode 100644 index 0000000..e5132d6 --- /dev/null +++ b/frontend/components/UnifiedLogViewer.tsx @@ -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([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [showFilters, setShowFilters] = useState(false); + const [showStats, setShowStats] = useState(true); + const [expandedLog, setExpandedLog] = useState(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 = {}; + 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 ; + if (statusCode >= 400 && statusCode < 500) return ; + if (statusCode >= 500) return ; + return ; + }; + + 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 = { + '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 ( +
+
+
+
+

๋กœ๊ทธ ์‹œ์Šคํ…œ ์ดˆ๊ธฐํ™” ์ค‘...

+
+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+
+
+

+ + ์‹œ์Šคํ…œ ๋กœ๊ทธ +

+

+ ๋ชจ๋“  API ์š”์ฒญ ๋ฐ ์—๋Ÿฌ ๋กœ๊ทธ๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค +

+
+
+ + + + +
+
+
+ + {/* Log Type Selector */} +
+ + + + +
+ + {/* Statistics */} + {showStats && ( +
+
+
+
+

์ด ์š”์ฒญ

+

+ {stats?.total_requests !== undefined ? stats.total_requests.toLocaleString() : 'Loading...'} +

+
+ +
+
+
+
+
+

์„ฑ๊ณต

+

+ {stats?.success_requests !== undefined ? stats.success_requests.toLocaleString() : 'Loading...'} +

+
+ +
+
+
+
+
+

ํด๋ผ์ด์–ธํŠธ ์—๋Ÿฌ

+

+ {stats?.client_error_requests !== undefined ? stats.client_error_requests.toLocaleString() : 'Loading...'} +

+
+ +
+
+
+
+
+

์„œ๋ฒ„ ์—๋Ÿฌ

+

+ {stats?.server_error_requests !== undefined ? stats.server_error_requests.toLocaleString() : 'Loading...'} +

+
+ +
+
+
+
+
+

ํ‰๊ท  ์‘๋‹ต์‹œ๊ฐ„

+

+ {stats?.average_response_time_ms !== undefined ? formatResponseTime(stats.average_response_time_ms) : 'Loading...'} +

+
+ +
+
+
+ )} + + {/* Filters */} + {showFilters && ( +
+
+
+ + setFilters({ ...filters, start_date: e.target.value })} + className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setFilters({ ...filters, end_date: e.target.value })} + className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" + /> +
+
+ + +
+
+ + setFilters({ ...filters, status_code: e.target.value })} + className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setFilters({ ...filters, endpoint: e.target.value })} + className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" + /> +
+
+
+ )} + + {/* Logs Table */} +
+ {loading ? ( +
+ +

๋กœ๊ทธ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+ ) : ( +
+ + + + + + + + + + + + + + + {logs.map((log) => ( + + + + + + + + + + + + {expandedLog === log.id && ( + + + + )} + + ))} + +
+ ์ƒํƒœ + + ์‹œ๊ฐ„ + + ๋ฉ”์„œ๋“œ + + ์—”๋“œํฌ์ธํŠธ + + ์ƒํƒœ์ฝ”๋“œ + + ์‘๋‹ต์‹œ๊ฐ„ + + IP + + ์ƒ์„ธ +
+ {getStatusIcon(log.status_code)} + + {formatDateTime(log.created_at)} + + + {log.method} + + + {log.endpoint} + + + {log.status_code} + + + {formatResponseTime(log.response_time_ms)} + + {log.client_ip || 'N/A'} + + +
+
+ {/* Request Information */} +
+

์š”์ฒญ ์ •๋ณด

+
+

Request ID: {log.request_id}

+

Full Path: {log.path}

+

User Agent: {log.user_agent || 'N/A'}

+ + {/* Data Source Info for successful requests */} + {log.status_code >= 200 && log.status_code < 300 && log.headers?.['X-Data-Source'] && ( +
+

+ ๋ฐ์ดํ„ฐ ์†Œ์Šค: + + {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']} + +

+
+ )} + {log.headers && Object.keys(log.headers).length > 0 && ( +
+ Headers: +
+                                        {JSON.stringify(log.headers, null, 2)}
+                                      
+
+ )} + {log.query_params && Object.keys(log.query_params).length > 0 && ( +
+ Query Parameters: +
+                                        {JSON.stringify(log.query_params, null, 2)}
+                                      
+
+ )} + {log.request_body && ( +
+ Request Body: +
+                                        {JSON.stringify(log.request_body, null, 2)}
+                                      
+
+ )} +
+
+ + {/* Error Information */} + {log.is_error && (log.error_message || log.status_code >= 400) && ( +
+

์—๋Ÿฌ ์ •๋ณด

+
+

Status Code: {log.status_code}

+ {log.error_type && ( +

Error Type: {log.error_type}

+ )} + {log.error_message && ( +

Error Message: {log.error_message}

+ )} + + {/* Error Response Body - show raw response from error_detail */} + {log.error_detail && ( +
+ ์—๋Ÿฌ ์‘๋‹ต: +
+                                          {typeof log.error_detail === 'string' ? log.error_detail : JSON.stringify(log.error_detail, null, 2)}
+                                        
+
+ )} + + {log.stack_trace && ( +
+ Stack Trace: +
+                                          {log.stack_trace}
+                                        
+
+ )} +
+
+ )} +
+
+ {logs.length === 0 && ( +
+ +

์กฐ๊ฑด์— ๋งž๋Š” ๋กœ๊ทธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

+
+ )} +
+ )} +
+
+
+ ); +}; + +export default UnifiedLogViewer; \ No newline at end of file diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts new file mode 100644 index 0000000..f90ebd0 --- /dev/null +++ b/frontend/lib/api.ts @@ -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 => { + 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 => { + const response = await api.post('/financial/data', request); + return response.data; + }, + + // ์ฃผ๊ฐ€ ๋ฐ์ดํ„ฐ ์กฐํšŒ + getPriceData: async (request: PriceDataRequest): Promise => { + const response = await api.post('/price/data', request); + return response.data; + }, + + // ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํ†ต๊ณ„ ์กฐํšŒ + getDatabaseStats: async (): Promise => { + 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 => { + const response = await api.get('/tickers'); + return response.data.tickers || []; + }, + + // ํšŒ์‚ฌ ์ •๋ณด ์กฐํšŒ + getCompanyInfo: async (ticker: string): Promise => { + 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 => { + 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) => { + 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) => { + 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 => { + 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 => { + const response = await api.get('/fred/endpoints'); + return response.data.data; + }, + + // FRED API ํ”„๋ก์‹œ ์š”์ฒญ (๋ฒ”์šฉ) + proxyRequest: async (request: FredProxyRequest): Promise => { + 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 => { + 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 => { + 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 => { + 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) => { + const qp = new URLSearchParams(params); + const { data } = await api.get(`/admin/requests/logs?${qp}`); + return data; + }, + + getErrorLogs: async (params?: Record) => { + const qp = new URLSearchParams(params); + const { data } = await api.get(`/admin/errors/logs?${qp}`); + return data; + }, + + getRequestStats: async (params?: Record) => { + const qp = new URLSearchParams(params); + const { data } = await api.get(`/admin/requests/stats?${qp}`); + return data; + }, + + getErrorStats: async (params?: Record) => { + const qp = new URLSearchParams(params); + const { data } = await api.get(`/admin/errors/stats?${qp}`); + return data; + }, + + deleteRequestLogs: async (params?: Record) => { + const qp = new URLSearchParams(params); + const { data } = await api.delete(`/admin/requests/logs?${qp}`); + return data; + }, + + deleteErrorLogs: async (params?: Record) => { + 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', + }); +}; \ No newline at end of file diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..52e831b --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..faa2e8d --- /dev/null +++ b/frontend/next.config.js @@ -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; \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..c14d49f --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,9861 @@ +{ + "name": "stock-oracle-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stock-oracle-frontend", + "version": "1.0.0", + "license": "MIT", + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz", + "integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@choojs/findup": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@choojs/findup/-/findup-0.2.1.tgz", + "integrity": "sha512-YstAqNb0MCN8PjdLCDfRsBcGVRN41f3vgLvaI0IrIcBp4AqILRSS0DeWNGkicC+f/zRIPJLc+9RURVSepwvfBw==", + "license": "MIT", + "dependencies": { + "commander": "^2.15.1" + }, + "bin": { + "findup": "bin/findup.js" + } + }, + "node_modules/@choojs/findup/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/@emnapi/core": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz", + "integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.4", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", + "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz", + "integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz", + "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", + "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", + "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz", + "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz", + "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz", + "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz", + "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", + "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", + "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", + "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", + "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz", + "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz", + "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz", + "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", + "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", + "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", + "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz", + "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.4.4" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", + "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz", + "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", + "integrity": "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/geojson-types": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-types/-/geojson-types-1.0.2.tgz", + "integrity": "sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==", + "license": "ISC" + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/mapbox-gl-supported": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.5.0.tgz", + "integrity": "sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==", + "license": "BSD-3-Clause", + "peerDependencies": { + "mapbox-gl": ">=0.32.1 <2.0.0" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-1.2.5.tgz", + "integrity": "sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", + "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz", + "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "15.4.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.6.tgz", + "integrity": "sha512-yHDKVTcHrZy/8TWhj0B23ylKv5ypocuCwey9ZqPyv4rPdUdRzpGCkSi03t04KBPyU96kxVtUqx6O3nE1kpxASQ==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.12.tgz", + "integrity": "sha512-+ZRSDFTv4aC96aMb5E41rMjysx8ApkryevnvEYZvPZO52KvkqP5rNExLUXJFr9P4s0f3oqNQR6vopCZsPWKDcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.4.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.6.tgz", + "integrity": "sha512-667R0RTP4DwxzmrqTs4Lr5dcEda9OxuZsVFsjVtxVMVhzSpo6nLclXejJVfQo2/g7/Z9qF3ETDmN3h65mTjpTQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.4.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.6.tgz", + "integrity": "sha512-KMSFoistFkaiQYVQQnaU9MPWtp/3m0kn2Xed1Ces5ll+ag1+rlac20sxG+MqhH2qYWX1O2GFOATQXEyxKiIscg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.4.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.6.tgz", + "integrity": "sha512-PnOx1YdO0W7m/HWFeYd2A6JtBO8O8Eb9h6nfJia2Dw1sRHoHpNf6lN1U4GKFRzRDBi9Nq2GrHk9PF3Vmwf7XVw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.4.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.6.tgz", + "integrity": "sha512-XBbuQddtY1p5FGPc2naMO0kqs4YYtLYK/8aPausI5lyOjr4J77KTG9mtlU4P3NwkLI1+OjsPzKVvSJdMs3cFaw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.4.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.4.6.tgz", + "integrity": "sha512-+WTeK7Qdw82ez3U9JgD+igBAP75gqZ1vbK6R8PlEEuY0OIe5FuYXA4aTjL811kWPf7hNeslD4hHK2WoM9W0IgA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.4.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.4.6.tgz", + "integrity": "sha512-XP824mCbgQsK20jlXKrUpZoh/iO3vUWhMpxCz8oYeagoiZ4V0TQiKy0ASji1KK6IAe3DYGfj5RfKP6+L2020OQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.4.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.6.tgz", + "integrity": "sha512-FxrsenhUz0LbgRkNWx6FRRJIPe/MI1JRA4W4EPd5leXO00AZ6YU8v5vfx4MDXTvN77lM/EqsE3+6d2CIeF5NYg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.4.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.6.tgz", + "integrity": "sha512-T4ufqnZ4u88ZheczkBTtOF+eKaM14V8kbjud/XrAakoM5DKQWjW09vD6B9fsdsWS2T7D5EY31hRHdta7QKWOng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@plotly/d3": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@plotly/d3/-/d3-3.8.2.tgz", + "integrity": "sha512-wvsNmh1GYjyJfyEBPKJLTMzgf2c2bEbSIL50lmqVUi+o1NHaLPi1Lb4v7VxXXJn043BhNyrxUrWI85Q+zmjOVA==", + "license": "BSD-3-Clause" + }, + "node_modules/@plotly/d3-sankey": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@plotly/d3-sankey/-/d3-sankey-0.7.2.tgz", + "integrity": "sha512-2jdVos1N3mMp3QW0k2q1ph7Gd6j5PY1YihBrwpkFnKqO+cqtZq3AdEYUeSGXMeLsBDQYiqTVcihYfk8vr5tqhw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1", + "d3-collection": "1", + "d3-shape": "^1.2.0" + } + }, + "node_modules/@plotly/d3-sankey-circular": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@plotly/d3-sankey-circular/-/d3-sankey-circular-0.33.1.tgz", + "integrity": "sha512-FgBV1HEvCr3DV7RHhDsPXyryknucxtfnLwPtCKKxdolKyTFYoLX/ibEfX39iFYIL7DYbVeRtP43dbFcrHNE+KQ==", + "license": "MIT", + "dependencies": { + "d3-array": "^1.2.1", + "d3-collection": "^1.0.4", + "d3-shape": "^1.2.0", + "elementary-circuits-directed-graph": "^1.0.4" + } + }, + "node_modules/@plotly/d3-sankey-circular/node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, + "node_modules/@plotly/d3-sankey-circular/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/@plotly/d3-sankey-circular/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/@plotly/d3-sankey/node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, + "node_modules/@plotly/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/@plotly/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/@plotly/mapbox-gl": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@plotly/mapbox-gl/-/mapbox-gl-1.13.4.tgz", + "integrity": "sha512-sR3/Pe5LqT/fhYgp4rT4aSFf1rTsxMbGiH6Hojc7PH36ny5Bn17iVFUjpzycafETURuFbLZUfjODO8LvSI+5zQ==", + "license": "SEE LICENSE IN LICENSE.txt", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/geojson-types": "^1.0.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^1.5.0", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^1.1.1", + "@mapbox/unitbezier": "^0.0.0", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.2", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.2.1", + "grid-index": "^1.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.1", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^7.1.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.1" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/@plotly/point-cluster": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/@plotly/point-cluster/-/point-cluster-3.1.9.tgz", + "integrity": "sha512-MwaI6g9scKf68Orpr1pHZ597pYx9uP8UEFXLPbsCmuw3a84obwz6pnMXGc90VhgDNeNiLEdlmuK7CPo+5PIxXw==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "binary-search-bounds": "^2.0.4", + "clamp": "^1.0.1", + "defined": "^1.0.0", + "dtype": "^2.0.0", + "flatten-vertex-data": "^1.0.2", + "is-obj": "^1.0.1", + "math-log2": "^1.0.1", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz", + "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@turf/area": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@turf/area/-/area-7.2.0.tgz", + "integrity": "sha512-zuTTdQ4eoTI9nSSjerIy4QwgvxqwJVciQJ8tOPuMHbXJ9N/dNjI7bU8tasjhxas/Cx3NE9NxVHtNpYHL0FSzoA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^7.2.0", + "@turf/meta": "^7.2.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bbox": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.2.0.tgz", + "integrity": "sha512-wzHEjCXlYZiDludDbXkpBSmv8Zu6tPGLmJ1sXQ6qDwpLE1Ew3mcWqt8AaxfTP5QwDNQa3sf2vvgTEzNbPQkCiA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^7.2.0", + "@turf/meta": "^7.2.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/centroid": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-7.2.0.tgz", + "integrity": "sha512-yJqDSw25T7P48au5KjvYqbDVZ7qVnipziVfZ9aSo7P2/jTE7d4BP21w0/XLi3T/9bry/t9PR1GDDDQljN4KfDw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^7.2.0", + "@turf/meta": "^7.2.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/helpers": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.2.0.tgz", + "integrity": "sha512-cXo7bKNZoa7aC7ydLmUR02oB3IgDe7MxiPuRz3cCtYQHn+BJ6h1tihmamYDWWUlPHgSNF0i3ATc4WmDECZafKw==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/meta": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.2.0.tgz", + "integrity": "sha512-igzTdHsQc8TV1RhPuOLVo74Px/hyPrVgVOTgjWQZzt3J9BVseCdpfY/0cJBdlSRI4S/yTmmHl7gAqjhpYH5Yaw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^7.2.0", + "@types/geojson": "^7946.0.10" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", + "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mapbox__point-geometry": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", + "license": "MIT" + }, + "node_modules/@types/mapbox__vector-tile": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", + "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/mapbox__point-geometry": "*", + "@types/pbf": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz", + "integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", + "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", + "integrity": "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/type-utils": "8.38.0", + "@typescript-eslint/utils": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.38.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz", + "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz", + "integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.38.0", + "@typescript-eslint/types": "^8.38.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz", + "integrity": "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz", + "integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz", + "integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/utils": "8.38.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz", + "integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz", + "integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.38.0", + "@typescript-eslint/tsconfig-utils": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz", + "integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz", + "integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.38.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/abs-svg-path": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", + "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/almost-equal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/almost-equal/-/almost-equal-1.1.0.tgz", + "integrity": "sha512-0V/PkoculFl5+0Lp47JoxUcO0xSxhIBvm+BxHdD/OgXNmdRpRHCFnKVuUoWyS9EzQP+otSGv0m9Lb4yVkQBn2A==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-bounds": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-bounds/-/array-bounds-1.0.1.tgz", + "integrity": "sha512-8wdW3ZGk6UjMPJx/glyEt0sLzzwAE1bhToPsO1W2pbpR2gULyxe3BjSiuJFheP50T/GgODVPz2fuMUmIywt8cQ==", + "license": "MIT" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-normalize": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array-normalize/-/array-normalize-1.1.4.tgz", + "integrity": "sha512-fCp0wKFLjvSPmCn4F5Tiw4M3lpMZoHlCjfcs7nNzuj3vqQQ1/a8cgB9DXcpDSn18c+coLnaW7rqfcYCvKbyJXg==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.0" + } + }, + "node_modules/array-range": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-range/-/array-range-1.0.1.tgz", + "integrity": "sha512-shdaI1zT3CVNL2hnx9c0JMc0ZogGaxDs5e85akgHWKYa0yVbIyp06Ind3dVkTj/uuFrzaHBOyqFzo+VV6aXgtA==", + "license": "MIT" + }, + "node_modules/array-rearrange": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/array-rearrange/-/array-rearrange-2.2.2.tgz", + "integrity": "sha512-UfobP5N12Qm4Qu4fwLDIi2v6+wZsSf6snYSxAMeKhrh37YGnNWZPRmVEKc/2wfms53TLQnzfpG8wCx2Y/6NG1w==", + "license": "MIT" + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", + "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/binary-search-bounds": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz", + "integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==", + "license": "MIT" + }, + "node_modules/bit-twiddle": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bit-twiddle/-/bit-twiddle-1.0.2.tgz", + "integrity": "sha512-B9UhK0DKFZhoTFcfvAzhqsjStvGJp9vYWf3+6SNTtdSQnvIgfkHbgHrg/e4+TH71N2GDu8tpmCVoyfrL1d7ntA==", + "license": "MIT" + }, + "node_modules/bitmap-sdf": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz", + "integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==", + "license": "MIT" + }, + "node_modules/bl": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", + "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/broadcast-channel": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz", + "integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "detect-node": "^2.1.0", + "js-sha3": "0.8.0", + "microseconds": "0.2.0", + "nano-time": "1.0.0", + "oblivious-set": "1.0.0", + "rimraf": "3.0.2", + "unload": "2.2.0" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001731", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz", + "integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvas-fit": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/canvas-fit/-/canvas-fit-1.5.0.tgz", + "integrity": "sha512-onIcjRpz69/Hx5bB5HGbYKUF2uC6QT6Gp+pfpGm3A7mPfcluSLV5v4Zu+oflDUwLdUw0rLIBhUbi0v8hM4FJQQ==", + "license": "MIT", + "dependencies": { + "element-size": "^1.1.1" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clamp": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/clamp/-/clamp-1.0.1.tgz", + "integrity": "sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==", + "license": "MIT" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-alpha": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/color-alpha/-/color-alpha-1.0.4.tgz", + "integrity": "sha512-lr8/t5NPozTSqli+duAN+x+no/2WaKTeWvxhHGN+aXT6AJ8vPlzLa7UriyjWak0pSC2jHol9JgjBYnnHsGha9A==", + "license": "MIT", + "dependencies": { + "color-parse": "^1.3.8" + } + }, + "node_modules/color-alpha/node_modules/color-parse": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.3.tgz", + "integrity": "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/color-id/-/color-id-1.1.0.tgz", + "integrity": "sha512-2iRtAn6dC/6/G7bBIo0uupVrIne1NsQJvJxZOBCzQOfk7jRq97feaDZ3RdzuHakRXXnHGNwglto3pqtRx1sX0g==", + "license": "MIT", + "dependencies": { + "clamp": "^1.0.1" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-normalize": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/color-normalize/-/color-normalize-1.5.0.tgz", + "integrity": "sha512-rUT/HDXMr6RFffrR53oX3HGWkDOP9goSAQGBkUaAYKjOE2JxozccdGyufageWDlInRAjm/jYPrf/Y38oa+7obw==", + "license": "MIT", + "dependencies": { + "clamp": "^1.0.1", + "color-rgba": "^2.1.1", + "dtype": "^2.0.0" + } + }, + "node_modules/color-parse": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-2.0.0.tgz", + "integrity": "sha512-g2Z+QnWsdHLppAbrpcFWo629kLOnOPtpxYV69GCqm92gqSgyXbzlfyN3MXs0412fPBkFmiuS+rXposgBgBa6Kg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, + "node_modules/color-rgba": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/color-rgba/-/color-rgba-2.1.1.tgz", + "integrity": "sha512-VaX97wsqrMwLSOR6H7rU1Doa2zyVdmShabKrPEIFywLlHoibgD3QW9Dw6fSqM4+H/LfjprDNAUUW31qEQcGzNw==", + "license": "MIT", + "dependencies": { + "clamp": "^1.0.1", + "color-parse": "^1.3.8", + "color-space": "^1.14.6" + } + }, + "node_modules/color-rgba/node_modules/color-parse": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.3.tgz", + "integrity": "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, + "node_modules/color-space": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/color-space/-/color-space-1.16.0.tgz", + "integrity": "sha512-A6WMiFzunQ8KEPFmj02OnnoUnqhmSaHaZ/0LVFcPTdlvm8+3aMJ5x1HRHy3bDHPkovkf4sS0f4wsVvwk71fKkg==", + "license": "MIT", + "dependencies": { + "hsluv": "^0.0.3", + "mumath": "^3.3.4" + } + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/country-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/country-regex/-/country-regex-1.1.0.tgz", + "integrity": "sha512-iSPlClZP8vX7MC3/u6s3lrDuoQyhQukh5LyABJ3hvfzbQ3Yyayd4fp04zjLnfi267B/B2FkumcWWgrbban7sSA==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-font": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/css-font/-/css-font-1.2.0.tgz", + "integrity": "sha512-V4U4Wps4dPDACJ4WpgofJ2RT5Yqwe1lEH6wlOOaIxMi0gTjdIijsc5FmxQlZ7ZZyKQkkutqqvULOp07l9c7ssA==", + "license": "MIT", + "dependencies": { + "css-font-size-keywords": "^1.0.0", + "css-font-stretch-keywords": "^1.0.1", + "css-font-style-keywords": "^1.0.1", + "css-font-weight-keywords": "^1.0.0", + "css-global-keywords": "^1.0.1", + "css-system-font-keywords": "^1.0.0", + "pick-by-alias": "^1.2.0", + "string-split-by": "^1.0.0", + "unquote": "^1.1.0" + } + }, + "node_modules/css-font-size-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-font-size-keywords/-/css-font-size-keywords-1.0.0.tgz", + "integrity": "sha512-Q+svMDbMlelgCfH/RVDKtTDaf5021O486ZThQPIpahnIjUkMUslC+WuOQSWTgGSrNCH08Y7tYNEmmy0hkfMI8Q==", + "license": "MIT" + }, + "node_modules/css-font-stretch-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-font-stretch-keywords/-/css-font-stretch-keywords-1.0.1.tgz", + "integrity": "sha512-KmugPO2BNqoyp9zmBIUGwt58UQSfyk1X5DbOlkb2pckDXFSAfjsD5wenb88fNrD6fvS+vu90a/tsPpb9vb0SLg==", + "license": "MIT" + }, + "node_modules/css-font-style-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-font-style-keywords/-/css-font-style-keywords-1.0.1.tgz", + "integrity": "sha512-0Fn0aTpcDktnR1RzaBYorIxQily85M2KXRpzmxQPgh8pxUN9Fcn00I8u9I3grNr1QXVgCl9T5Imx0ZwKU973Vg==", + "license": "MIT" + }, + "node_modules/css-font-weight-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-font-weight-keywords/-/css-font-weight-keywords-1.0.0.tgz", + "integrity": "sha512-5So8/NH+oDD+EzsnF4iaG4ZFHQ3vaViePkL1ZbZ5iC/KrsCY+WHq/lvOgrtmuOQ9pBBZ1ADGpaf+A4lj1Z9eYA==", + "license": "MIT" + }, + "node_modules/css-global-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-global-keywords/-/css-global-keywords-1.0.1.tgz", + "integrity": "sha512-X1xgQhkZ9n94WDwntqst5D/FKkmiU0GlJSFZSV3kLvyJ1WC5VeyoXDOuleUD+SIuH9C7W05is++0Woh0CGfKjQ==", + "license": "MIT" + }, + "node_modules/css-loader": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz", + "integrity": "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.33", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-system-font-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-system-font-keywords/-/css-system-font-keywords-1.0.0.tgz", + "integrity": "sha512-1umTtVd/fXS25ftfjB71eASCrYhilmEsvDEI6wG/QplnmlfmVM5HkZ/ZX46DT5K3eblFPgLUHt5BRCb0YXkSFA==", + "license": "MIT" + }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/d3-collection": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", + "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", + "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-force": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.2.1.tgz", + "integrity": "sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-collection": "1", + "d3-dispatch": "1", + "d3-quadtree": "1", + "d3-timer": "1" + } + }, + "node_modules/d3-force/node_modules/d3-timer": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", + "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-geo": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz", + "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1" + } + }, + "node_modules/d3-geo-projection": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-2.9.0.tgz", + "integrity": "sha512-ZULvK/zBn87of5rWAfFMc9mJOipeSo57O+BBitsKIXmU4rTVAnX1kSsJkE0R+TxY8pGNoM1nbyRRE7GYHhdOEQ==", + "license": "BSD-3-Clause", + "dependencies": { + "commander": "2", + "d3-array": "1", + "d3-geo": "^1.12.0", + "resolve": "^1.1.10" + }, + "bin": { + "geo2svg": "bin/geo2svg", + "geograticule": "bin/geograticule", + "geoproject": "bin/geoproject", + "geoquantize": "bin/geoquantize", + "geostitch": "bin/geostitch" + } + }, + "node_modules/d3-geo-projection/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/d3-geo-projection/node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-geo/node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-hierarchy": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", + "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz", + "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==", + "license": "BSD-3-Clause" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/defined": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz", + "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-kerning": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-kerning/-/detect-kerning-2.1.2.tgz", + "integrity": "sha512-I3JIbrnKPAntNLl1I6TpSQQdQ4AutYzv/sKMFKbepawV/hlH0GmYKhUoOEMd4xqaUHT+Bm0f4127lh5qs1m1tw==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/draw-svg-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/draw-svg-path/-/draw-svg-path-1.0.0.tgz", + "integrity": "sha512-P8j3IHxcgRMcY6sDzr0QvJDLzBnJJqpTG33UZ2Pvp8rw0apCHhJCWqYprqrXjrgHnJ6tuhP1iTJSAodPDHxwkg==", + "license": "MIT", + "dependencies": { + "abs-svg-path": "~0.1.1", + "normalize-svg-path": "~0.1.0" + } + }, + "node_modules/dtype": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dtype/-/dtype-2.0.0.tgz", + "integrity": "sha512-s2YVcLKdFGS0hpFqJaTwscsyt0E8nNFdmo73Ocd81xNPj4URI4rj6D60A+vFMIw7BXWlb4yRkEwfBqcZzPGiZg==", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/dup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dup/-/dup-1.0.0.tgz", + "integrity": "sha512-Bz5jxMMC0wgp23Zm15ip1x8IhYRqJvF3nFC0UInJUDkN1z4uNPk9jTnfCUJXbOGiQ1JbXLQsiV41Fb+HXcj5BA==", + "license": "MIT" + }, + "node_modules/duplexify": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.194", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.194.tgz", + "integrity": "sha512-SdnWJwSUot04UR51I2oPD8kuP2VI37/CADR1OHsFOUzZIvfWJBO6q11k5P/uKNyTT3cdOsnyjkrZ+DDShqYqJA==", + "license": "ISC" + }, + "node_modules/element-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/element-size/-/element-size-1.1.1.tgz", + "integrity": "sha512-eaN+GMOq/Q+BIWy0ybsgpcYImjGIdNLyjLFJU4XsLHXYQao5jCNb36GyN6C2qwmDDYSfIBmKpPpr4VnBdLCsPQ==", + "license": "MIT" + }, + "node_modules/elementary-circuits-directed-graph": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/elementary-circuits-directed-graph/-/elementary-circuits-directed-graph-1.3.1.tgz", + "integrity": "sha512-ZEiB5qkn2adYmpXGnJKkxT8uJHlW/mxmBpmeqawEHzPxh9HkLD4/1mFYX5l0On+f6rcPIt8/EWlRU2Vo3fX6dQ==", + "license": "MIT", + "dependencies": { + "strongly-connected-components": "^1.0.1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "license": "MIT", + "peer": true + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/es6-weak-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", + "license": "ISC", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-next": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.12.tgz", + "integrity": "sha512-ktW3XLfd+ztEltY5scJNjxjHwtKWk6vU2iwzZqSN09UsbBmMeE/cVlJ1yESg6Yx5LW7p/Z8WzUAgYXGLEmGIpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "15.5.12", + "@rushstack/eslint-patch": "^1.10.3", + "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^5.0.0" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/falafel": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/falafel/-/falafel-2.2.5.tgz", + "integrity": "sha512-HuC1qF9iTnHDnML9YZAdCDQwT0yKl/U55K4XSUXqGAA2GLoafFgWRqdAbhWJxXaYD4pyoVxAJ8wH670jMpI9DQ==", + "license": "MIT", + "dependencies": { + "acorn": "^7.1.1", + "isarray": "^2.0.1" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/falafel/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-isnumeric": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-isnumeric/-/fast-isnumeric-1.1.4.tgz", + "integrity": "sha512-1mM8qOr2LYz8zGaUdmiqRDiuue00Dxjgcb1NQR7TnhLVh6sQyngP9xvLo7Sl7LZpP/sk5eb+bcyWXw530NTBZw==", + "license": "MIT", + "dependencies": { + "is-string-blank": "^1.0.1" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/flatten-vertex-data": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/flatten-vertex-data/-/flatten-vertex-data-1.0.2.tgz", + "integrity": "sha512-BvCBFK2NZqerFTdMDgqfHBwxYWnxeCkwONsw6PvBMcUXqo8U/KDWwmXhqx1x2kLIg7DqIsJfOaJFOmlua3Lxuw==", + "license": "MIT", + "dependencies": { + "dtype": "^2.0.0" + } + }, + "node_modules/font-atlas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/font-atlas/-/font-atlas-2.1.0.tgz", + "integrity": "sha512-kP3AmvX+HJpW4w3d+PiPR2X6E1yvsBXt2yhuCw+yReO9F1WYhvZwx3c95DGZGwg9xYzDGrgJYa885xmVA+28Cg==", + "license": "MIT", + "dependencies": { + "css-font": "^1.0.0" + } + }, + "node_modules/font-measure": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/font-measure/-/font-measure-1.2.2.tgz", + "integrity": "sha512-mRLEpdrWzKe9hbfaF3Qpr06TAjquuBVP5cHy4b3hyeNdjc9i0PO6HniGsX5vjL5OWv7+Bd++NiooNpT/s8BvIA==", + "license": "MIT", + "dependencies": { + "css-font": "^1.2.0" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/from2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", + "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/geojson-vt": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", + "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", + "license": "ISC" + }, + "node_modules/get-canvas-context": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-canvas-context/-/get-canvas-context-1.0.2.tgz", + "integrity": "sha512-LnpfLf/TNzr9zVOGiIY6aKCz8EKuXmlYNV7CM2pUjBa/B+c2I15tS7KLySep75+FuerJdmArvJLcsAXWEy2H0A==", + "license": "MIT" + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gl-mat4": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gl-mat4/-/gl-mat4-1.2.0.tgz", + "integrity": "sha512-sT5C0pwB1/e9G9AvAoLsoaJtbMGjfd/jfxo8jMCKqYYEnjZuFvqV5rehqar0538EmssjdDeiEWnKyBSTw7quoA==", + "license": "Zlib" + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/gl-text": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/gl-text/-/gl-text-1.4.0.tgz", + "integrity": "sha512-o47+XBqLCj1efmuNyCHt7/UEJmB9l66ql7pnobD6p+sgmBUdzfMZXIF0zD2+KRfpd99DJN+QXdvTFAGCKCVSmQ==", + "license": "MIT", + "dependencies": { + "bit-twiddle": "^1.0.2", + "color-normalize": "^1.5.0", + "css-font": "^1.2.0", + "detect-kerning": "^2.1.2", + "es6-weak-map": "^2.0.3", + "flatten-vertex-data": "^1.0.2", + "font-atlas": "^2.1.0", + "font-measure": "^1.2.2", + "gl-util": "^3.1.2", + "is-plain-obj": "^1.1.0", + "object-assign": "^4.1.1", + "parse-rect": "^1.2.0", + "parse-unit": "^1.0.1", + "pick-by-alias": "^1.2.0", + "regl": "^2.0.0", + "to-px": "^1.0.1", + "typedarray-pool": "^1.1.0" + } + }, + "node_modules/gl-util": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/gl-util/-/gl-util-3.1.3.tgz", + "integrity": "sha512-dvRTggw5MSkJnCbh74jZzSoTOGnVYK+Bt+Ckqm39CVcl6+zSsxqWk4lr5NKhkqXHL6qvZAU9h17ZF8mIskY9mA==", + "license": "MIT", + "dependencies": { + "is-browser": "^2.0.1", + "is-firefox": "^1.0.3", + "is-plain-obj": "^1.1.0", + "number-is-integer": "^1.0.1", + "object-assign": "^4.1.0", + "pick-by-alias": "^1.2.0", + "weak-map": "^1.0.5" + } + }, + "node_modules/glob": { + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", + "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/global-prefix": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", + "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", + "license": "MIT", + "dependencies": { + "ini": "^4.1.3", + "kind-of": "^6.0.3", + "which": "^4.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/global-prefix/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glsl-inject-defines": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/glsl-inject-defines/-/glsl-inject-defines-1.0.3.tgz", + "integrity": "sha512-W49jIhuDtF6w+7wCMcClk27a2hq8znvHtlGnrYkSWEr8tHe9eA2dcnohlcAmxLYBSpSSdzOkRdyPTrx9fw49+A==", + "license": "MIT", + "dependencies": { + "glsl-token-inject-block": "^1.0.0", + "glsl-token-string": "^1.0.1", + "glsl-tokenizer": "^2.0.2" + } + }, + "node_modules/glsl-resolve": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/glsl-resolve/-/glsl-resolve-0.0.1.tgz", + "integrity": "sha512-xxFNsfnhZTK9NBhzJjSBGX6IOqYpvBHxxmo+4vapiljyGNCY0Bekzn0firQkQrazK59c1hYxMDxYS8MDlhw4gA==", + "license": "MIT", + "dependencies": { + "resolve": "^0.6.1", + "xtend": "^2.1.2" + } + }, + "node_modules/glsl-resolve/node_modules/resolve": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-0.6.3.tgz", + "integrity": "sha512-UHBY3viPlJKf85YijDUcikKX6tmF4SokIDp518ZDVT92JNDcG5uKIthaT/owt3Sar0lwtOafsQuwrg22/v2Dwg==", + "license": "MIT" + }, + "node_modules/glsl-resolve/node_modules/xtend": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.2.0.tgz", + "integrity": "sha512-SLt5uylT+4aoXxXuwtQp5ZnMMzhDb1Xkg4pEqc00WUJCQifPfV9Ub1VrNhp9kXkrjZD2I2Hl8WnjP37jzZLPZw==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/glsl-token-assignments": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/glsl-token-assignments/-/glsl-token-assignments-2.0.2.tgz", + "integrity": "sha512-OwXrxixCyHzzA0U2g4btSNAyB2Dx8XrztY5aVUCjRSh4/D0WoJn8Qdps7Xub3sz6zE73W3szLrmWtQ7QMpeHEQ==", + "license": "MIT" + }, + "node_modules/glsl-token-defines": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/glsl-token-defines/-/glsl-token-defines-1.0.0.tgz", + "integrity": "sha512-Vb5QMVeLjmOwvvOJuPNg3vnRlffscq2/qvIuTpMzuO/7s5kT+63iL6Dfo2FYLWbzuiycWpbC0/KV0biqFwHxaQ==", + "license": "MIT", + "dependencies": { + "glsl-tokenizer": "^2.0.0" + } + }, + "node_modules/glsl-token-depth": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glsl-token-depth/-/glsl-token-depth-1.1.2.tgz", + "integrity": "sha512-eQnIBLc7vFf8axF9aoi/xW37LSWd2hCQr/3sZui8aBJnksq9C7zMeUYHVJWMhFzXrBU7fgIqni4EhXVW4/krpg==", + "license": "MIT" + }, + "node_modules/glsl-token-descope": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/glsl-token-descope/-/glsl-token-descope-1.0.2.tgz", + "integrity": "sha512-kS2PTWkvi/YOeicVjXGgX5j7+8N7e56srNDEHDTVZ1dcESmbmpmgrnpjPcjxJjMxh56mSXYoFdZqb90gXkGjQw==", + "license": "MIT", + "dependencies": { + "glsl-token-assignments": "^2.0.0", + "glsl-token-depth": "^1.1.0", + "glsl-token-properties": "^1.0.0", + "glsl-token-scope": "^1.1.0" + } + }, + "node_modules/glsl-token-inject-block": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/glsl-token-inject-block/-/glsl-token-inject-block-1.1.0.tgz", + "integrity": "sha512-q/m+ukdUBuHCOtLhSr0uFb/qYQr4/oKrPSdIK2C4TD+qLaJvqM9wfXIF/OOBjuSA3pUoYHurVRNao6LTVVUPWA==", + "license": "MIT" + }, + "node_modules/glsl-token-properties": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/glsl-token-properties/-/glsl-token-properties-1.0.1.tgz", + "integrity": "sha512-dSeW1cOIzbuUoYH0y+nxzwK9S9O3wsjttkq5ij9ZGw0OS41BirKJzzH48VLm8qLg+au6b0sINxGC0IrGwtQUcA==", + "license": "MIT" + }, + "node_modules/glsl-token-scope": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glsl-token-scope/-/glsl-token-scope-1.1.2.tgz", + "integrity": "sha512-YKyOMk1B/tz9BwYUdfDoHvMIYTGtVv2vbDSLh94PT4+f87z21FVdou1KNKgF+nECBTo0fJ20dpm0B1vZB1Q03A==", + "license": "MIT" + }, + "node_modules/glsl-token-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/glsl-token-string/-/glsl-token-string-1.0.1.tgz", + "integrity": "sha512-1mtQ47Uxd47wrovl+T6RshKGkRRCYWhnELmkEcUAPALWGTFe2XZpH3r45XAwL2B6v+l0KNsCnoaZCSnhzKEksg==", + "license": "MIT" + }, + "node_modules/glsl-token-whitespace-trim": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/glsl-token-whitespace-trim/-/glsl-token-whitespace-trim-1.0.0.tgz", + "integrity": "sha512-ZJtsPut/aDaUdLUNtmBYhaCmhIjpKNg7IgZSfX5wFReMc2vnj8zok+gB/3Quqs0TsBSX/fGnqUUYZDqyuc2xLQ==", + "license": "MIT" + }, + "node_modules/glsl-tokenizer": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/glsl-tokenizer/-/glsl-tokenizer-2.1.5.tgz", + "integrity": "sha512-XSZEJ/i4dmz3Pmbnpsy3cKh7cotvFlBiZnDOwnj/05EwNp2XrhQ4XKJxT7/pDt4kp4YcpRSKz8eTV7S+mwV6MA==", + "license": "MIT", + "dependencies": { + "through2": "^0.6.3" + } + }, + "node_modules/glsl-tokenizer/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/glsl-tokenizer/node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/glsl-tokenizer/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/glsl-tokenizer/node_modules/through2": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz", + "integrity": "sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg==", + "license": "MIT", + "dependencies": { + "readable-stream": ">=1.0.33-1 <1.1.0-0", + "xtend": ">=4.0.0 <4.1.0-0" + } + }, + "node_modules/glslify": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/glslify/-/glslify-7.1.1.tgz", + "integrity": "sha512-bud98CJ6kGZcP9Yxcsi7Iz647wuDz3oN+IZsjCRi5X1PI7t/xPKeL0mOwXJjo+CRZMqvq0CkSJiywCcY7kVYog==", + "license": "MIT", + "dependencies": { + "bl": "^2.2.1", + "concat-stream": "^1.5.2", + "duplexify": "^3.4.5", + "falafel": "^2.1.0", + "from2": "^2.3.0", + "glsl-resolve": "0.0.1", + "glsl-token-whitespace-trim": "^1.0.0", + "glslify-bundle": "^5.0.0", + "glslify-deps": "^1.2.5", + "minimist": "^1.2.5", + "resolve": "^1.1.5", + "stack-trace": "0.0.9", + "static-eval": "^2.0.5", + "through2": "^2.0.1", + "xtend": "^4.0.0" + }, + "bin": { + "glslify": "bin.js" + } + }, + "node_modules/glslify-bundle": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/glslify-bundle/-/glslify-bundle-5.1.1.tgz", + "integrity": "sha512-plaAOQPv62M1r3OsWf2UbjN0hUYAB7Aph5bfH58VxJZJhloRNbxOL9tl/7H71K7OLJoSJ2ZqWOKk3ttQ6wy24A==", + "license": "MIT", + "dependencies": { + "glsl-inject-defines": "^1.0.1", + "glsl-token-defines": "^1.0.0", + "glsl-token-depth": "^1.1.1", + "glsl-token-descope": "^1.0.2", + "glsl-token-scope": "^1.1.1", + "glsl-token-string": "^1.0.1", + "glsl-token-whitespace-trim": "^1.0.0", + "glsl-tokenizer": "^2.0.2", + "murmurhash-js": "^1.0.0", + "shallow-copy": "0.0.1" + } + }, + "node_modules/glslify-deps": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/glslify-deps/-/glslify-deps-1.3.2.tgz", + "integrity": "sha512-7S7IkHWygJRjcawveXQjRXLO2FTjijPDYC7QfZyAQanY+yGLCFHYnPtsGT9bdyHiwPTw/5a1m1M9hamT2aBpag==", + "license": "ISC", + "dependencies": { + "@choojs/findup": "^0.2.0", + "events": "^3.2.0", + "glsl-resolve": "0.0.1", + "glsl-tokenizer": "^2.0.0", + "graceful-fs": "^4.1.2", + "inherits": "^2.0.1", + "map-limit": "0.0.1", + "resolve": "^1.0.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/grid-index": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", + "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-hover": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-hover/-/has-hover-1.0.1.tgz", + "integrity": "sha512-0G6w7LnlcpyDzpeGUTuT0CEw05+QlMuGVk1IHNAlHrGJITGodjZu3x8BNDUMfKJSZXNB2ZAclqc1bvrd+uUpfg==", + "license": "MIT", + "dependencies": { + "is-browser": "^2.0.1" + } + }, + "node_modules/has-passive-events": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-passive-events/-/has-passive-events-1.0.0.tgz", + "integrity": "sha512-2vSj6IeIsgvsRMyeQ0JaCX5Q3lX4zMn5HpoVc7MEhQ6pv8Iq9rsXjsp+E5ZwaT7T0xhMT0KmU8gtt1EFVdbJiw==", + "license": "MIT", + "dependencies": { + "is-browser": "^2.0.1" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hsluv": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/hsluv/-/hsluv-0.0.3.tgz", + "integrity": "sha512-08iL2VyCRbkQKBySkSh6m8zMUa3sADAxGVWs3Z1aPcUkTJeK0ETG4Fc27tEmQBGUAXZjIsXOZqBvacuVNSC/fQ==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT", + "optional": true + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-browser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-browser/-/is-browser-2.1.0.tgz", + "integrity": "sha512-F5rTJxDQ2sW81fcfOR1GnCXT6sVJC104fCyfj+mjpwNEwaPYSn5fte5jiHmBg3DHsIoL/l8Kvw5VN5SsTRcRFQ==", + "license": "MIT" + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-finite": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", + "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-firefox": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-firefox/-/is-firefox-1.0.3.tgz", + "integrity": "sha512-6Q9ITjvWIm0Xdqv+5U12wgOKEM2KoBw4Y926m0OFkvlCxnbG94HKAsVz8w3fWcfAS5YA2fJORXX1dLrkprCCxA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-iexplorer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-iexplorer/-/is-iexplorer-1.0.0.tgz", + "integrity": "sha512-YeLzceuwg3K6O0MLM3UyUUjKAlyULetwryFp1mHy1I5PfArK0AEqlfa+MR4gkJjcbuJXoDJCvXbyqZVf5CR2Sg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-mobile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-4.0.0.tgz", + "integrity": "sha512-mlcHZA84t1qLSuWkt2v0I2l61PYdyQDt4aG1mLIXF5FDMm4+haBCxCPYSr/uwqQNRk1MiTizn0ypEuRAOLRAew==", + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string-blank": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-string-blank/-/is-string-blank-1.0.1.tgz", + "integrity": "sha512-9H+ZBCVs3L9OYqv8nuUAzpcT9OTgMD1yAWrG7ihlnibdkbtB850heAmYWxHuXc4CHy4lKeK69tN+ny1K7gBIrw==", + "license": "MIT" + }, + "node_modules/is-svg-path": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-svg-path/-/is-svg-path-1.0.2.tgz", + "integrity": "sha512-Lj4vePmqpPR1ZnRctHv8ltSh1OrSxHkhUkd7wi+VQdcdP15/KvQFyk7LhNuM7ZW0EVbJz8kZLVmL9quLrfq4Kg==", + "license": "MIT" + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", + "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-sha3": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/lucide-react": { + "version": "0.294.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.294.0.tgz", + "integrity": "sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/map-limit": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/map-limit/-/map-limit-0.0.1.tgz", + "integrity": "sha512-pJpcfLPnIF/Sk3taPW21G/RQsEEirGaFpCW3oXRwH9dnFHPHNGjNyvh++rdmC2fNqEaTw2MhYJraoJWAHx8kEg==", + "license": "MIT", + "dependencies": { + "once": "~1.3.0" + } + }, + "node_modules/map-limit/node_modules/once": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz", + "integrity": "sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/mapbox-gl": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.3.tgz", + "integrity": "sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==", + "license": "SEE LICENSE IN LICENSE.txt", + "peer": true, + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/geojson-types": "^1.0.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^1.5.0", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^1.1.1", + "@mapbox/unitbezier": "^0.0.0", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.2", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.2.1", + "grid-index": "^1.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.1", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^7.1.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.1" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/maplibre-gl": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", + "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/maplibre-gl-style-spec": "^20.3.1", + "@types/geojson": "^7946.0.14", + "@types/geojson-vt": "3.2.5", + "@types/mapbox__point-geometry": "^0.1.4", + "@types/mapbox__vector-tile": "^1.3.4", + "@types/pbf": "^3.0.5", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.0", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.3", + "global-prefix": "^4.0.0", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^3.3.0", + "potpack": "^2.0.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0", + "vt-pbf": "^3.1.3" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/maplibre-gl/node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/maplibre-gl/node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/maplibre-gl/node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/geojson-vt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", + "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/maplibre-gl/node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/match-sorter": { + "version": "6.3.4", + "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.4.tgz", + "integrity": "sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.8", + "remove-accents": "0.5.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/math-log2": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/math-log2/-/math-log2-1.0.1.tgz", + "integrity": "sha512-9W0yGtkaMAkf74XGYVy4Dqw3YUMnTNB2eeiw9aQbUl4A3KmuCEHTt2DgAB07ENzOYAjsYSAYufkAq0Zd+jU7zA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT", + "peer": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/microseconds": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz", + "integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mouse-change": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/mouse-change/-/mouse-change-1.4.0.tgz", + "integrity": "sha512-vpN0s+zLL2ykyyUDh+fayu9Xkor5v/zRD9jhSqjRS1cJTGS0+oakVZzNm5n19JvvEj0you+MXlYTpNxUDQUjkQ==", + "license": "MIT", + "dependencies": { + "mouse-event": "^1.0.0" + } + }, + "node_modules/mouse-event": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/mouse-event/-/mouse-event-1.0.5.tgz", + "integrity": "sha512-ItUxtL2IkeSKSp9cyaX2JLUuKk2uMoxBg4bbOWVd29+CskYJR9BGsUqtXenNzKbnDshvupjUewDIYVrOB6NmGw==", + "license": "MIT" + }, + "node_modules/mouse-event-offset": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mouse-event-offset/-/mouse-event-offset-3.0.2.tgz", + "integrity": "sha512-s9sqOs5B1Ykox3Xo8b3Ss2IQju4UwlW6LSR+Q5FXWpprJ5fzMLefIIItr3PH8RwzfGy6gxs/4GAmiNuZScE25w==", + "license": "MIT" + }, + "node_modules/mouse-wheel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mouse-wheel/-/mouse-wheel-1.2.0.tgz", + "integrity": "sha512-+OfYBiUOCTWcTECES49neZwL5AoGkXE+lFjIvzwNCnYRlso+EnfvovcBxGoyQ0yQt806eSPjS675K0EwWknXmw==", + "license": "MIT", + "dependencies": { + "right-now": "^1.0.0", + "signum": "^1.0.0", + "to-px": "^1.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mumath": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/mumath/-/mumath-3.3.4.tgz", + "integrity": "sha512-VAFIOG6rsxoc7q/IaY3jdjmrsuX9f15KlRLYTHmixASBZkZEKC1IFqE2BC5CdhXmK6WLM1Re33z//AGmeRI6FA==", + "deprecated": "Redundant dependency in your project.", + "license": "Unlicense", + "dependencies": { + "almost-equal": "^1.1.0" + } + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nano-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz", + "integrity": "sha512-flnngywOoQ0lLQOTRNexn2gGSNuM9bKj9RZAWSzhQ+UJYaAFG9bac4DW9VHjUAzrOaIcajHybCTHe/bkvozQqA==", + "license": "ISC", + "dependencies": { + "big-integer": "^1.6.16" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.2.tgz", + "integrity": "sha512-tWVJxJHmBWLy69PvO96TZMZDrzmw5KeiZBz3RHmiM2XZ9grBJ2WgMAFVVg25nqp3ZjTFUs2Ftw1JhscL3Teliw==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/native-promise-only": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz", + "integrity": "sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/needle": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", + "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT", + "peer": true + }, + "node_modules/next": { + "version": "15.4.6", + "resolved": "https://registry.npmjs.org/next/-/next-15.4.6.tgz", + "integrity": "sha512-us++E/Q80/8+UekzB3SAGs71AlLDsadpFMXVNM/uQ0BMwsh9m3mr0UNQIfjKed8vpWXsASe+Qifrnu1oLIcKEQ==", + "license": "MIT", + "dependencies": { + "@next/env": "15.4.6", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.4.6", + "@next/swc-darwin-x64": "15.4.6", + "@next/swc-linux-arm64-gnu": "15.4.6", + "@next/swc-linux-arm64-musl": "15.4.6", + "@next/swc-linux-x64-gnu": "15.4.6", + "@next/swc-linux-x64-musl": "15.4.6", + "@next/swc-win32-arm64-msvc": "15.4.6", + "@next/swc-win32-x64-msvc": "15.4.6", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-svg-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-0.1.0.tgz", + "integrity": "sha512-1/kmYej2iedi5+ROxkRESL/pI02pkg0OBnaR4hJkSIX6+ORzepwbuUXfrdZaPjysTsJInj0Rj5NuX027+dMBvA==", + "license": "MIT" + }, + "node_modules/number-is-integer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-integer/-/number-is-integer-1.0.1.tgz", + "integrity": "sha512-Dq3iuiFBkrbmuQjGFFF3zckXNCQoSD37/SdSbgcBailUx6knDvDwb5CympBgcoWHy36sfS12u74MHYkXyHq6bg==", + "license": "MIT", + "dependencies": { + "is-finite": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/oblivious-set": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz", + "integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw==", + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parenthesis": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/parenthesis/-/parenthesis-3.1.8.tgz", + "integrity": "sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==", + "license": "MIT" + }, + "node_modules/parse-rect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/parse-rect/-/parse-rect-1.2.0.tgz", + "integrity": "sha512-4QZ6KYbnE6RTwg9E0HpLchUM9EZt6DnDxajFZZDSV4p/12ZJEvPO702DZpGvRYEPo00yKDys7jASi+/w7aO8LA==", + "license": "MIT", + "dependencies": { + "pick-by-alias": "^1.2.0" + } + }, + "node_modules/parse-svg-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", + "license": "MIT" + }, + "node_modules/parse-unit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-unit/-/parse-unit-1.0.1.tgz", + "integrity": "sha512-hrqldJHokR3Qj88EIlV/kAyAi/G5R2+R56TBANxNMy0uPlYcttx0jnMW6Yx5KsKPSbC3KddM/7qQm3+0wEXKxg==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT" + }, + "node_modules/pick-by-alias": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pick-by-alias/-/pick-by-alias-1.2.0.tgz", + "integrity": "sha512-ESj2+eBxhGrcA1azgHs7lARG5+5iLakc/6nlfbpjcLl00HuuUOIuORhYXN4D1HfvMSKuVtFQjAlnwi1JHEeDIw==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/plotly.js": { + "version": "2.35.3", + "resolved": "https://registry.npmjs.org/plotly.js/-/plotly.js-2.35.3.tgz", + "integrity": "sha512-7RaC6FxmCUhpD6H4MpD+QLUu3hCn76I11rotRefrh3m1iDvWqGnVqVk9dSaKmRAhFD3vsNsYea0OxnR1rc2IzQ==", + "license": "MIT", + "dependencies": { + "@plotly/d3": "3.8.2", + "@plotly/d3-sankey": "0.7.2", + "@plotly/d3-sankey-circular": "0.33.1", + "@plotly/mapbox-gl": "1.13.4", + "@turf/area": "^7.1.0", + "@turf/bbox": "^7.1.0", + "@turf/centroid": "^7.1.0", + "base64-arraybuffer": "^1.0.2", + "canvas-fit": "^1.5.0", + "color-alpha": "1.0.4", + "color-normalize": "1.5.0", + "color-parse": "2.0.0", + "color-rgba": "2.1.1", + "country-regex": "^1.1.0", + "css-loader": "^7.1.2", + "d3-force": "^1.2.1", + "d3-format": "^1.4.5", + "d3-geo": "^1.12.1", + "d3-geo-projection": "^2.9.0", + "d3-hierarchy": "^1.1.9", + "d3-interpolate": "^3.0.1", + "d3-time": "^1.1.0", + "d3-time-format": "^2.2.3", + "fast-isnumeric": "^1.1.4", + "gl-mat4": "^1.2.0", + "gl-text": "^1.4.0", + "has-hover": "^1.0.1", + "has-passive-events": "^1.0.0", + "is-mobile": "^4.0.0", + "maplibre-gl": "^4.5.2", + "mouse-change": "^1.4.0", + "mouse-event-offset": "^3.0.2", + "mouse-wheel": "^1.2.0", + "native-promise-only": "^0.8.1", + "parse-svg-path": "^0.1.2", + "point-in-polygon": "^1.1.0", + "polybooljs": "^1.2.2", + "probe-image-size": "^7.2.3", + "regl": "npm:@plotly/regl@^2.1.2", + "regl-error2d": "^2.0.12", + "regl-line2d": "^3.1.3", + "regl-scatter2d": "^3.3.1", + "regl-splom": "^1.0.14", + "strongly-connected-components": "^1.0.1", + "style-loader": "^4.0.0", + "superscript-text": "^1.0.0", + "svg-path-sdf": "^1.1.3", + "tinycolor2": "^1.4.2", + "to-px": "1.0.1", + "topojson-client": "^3.1.0", + "webgl-context": "^2.2.0", + "world-calendars": "^1.0.3" + } + }, + "node_modules/plotly.js/node_modules/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", + "license": "BSD-3-Clause" + }, + "node_modules/plotly.js/node_modules/d3-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", + "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==", + "license": "BSD-3-Clause" + }, + "node_modules/plotly.js/node_modules/d3-time-format": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz", + "integrity": "sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-time": "1" + } + }, + "node_modules/point-in-polygon": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", + "integrity": "sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==", + "license": "MIT" + }, + "node_modules/polybooljs": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/polybooljs/-/polybooljs-1.2.2.tgz", + "integrity": "sha512-ziHW/02J0XuNuUtmidBc6GXE8YohYydp3DWPWXYsd7O721TjcmN+k6ezjdwkDqep+gnWnFY+yqZHvzElra2oCg==", + "license": "MIT" + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/probe-image-size": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.2.3.tgz", + "integrity": "sha512-HubhG4Rb2UH8YtV4ba0Vp5bQ7L78RTONYu/ujmCu5nBI8wGv24s4E9xSKBi0N1MowRpxk76pFCpJtW0KPzOK0w==", + "license": "MIT", + "dependencies": { + "lodash.merge": "^4.6.2", + "needle": "^2.5.2", + "stream-parser": "~0.3.1" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-plotly.js": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-plotly.js/-/react-plotly.js-2.6.0.tgz", + "integrity": "sha512-g93xcyhAVCSt9kV1svqG1clAEdL6k3U+jjuSzfTV7owaSU9Go6Ph8bl25J+jKfKvIGAEYpe4qj++WHJuc9IaeA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "plotly.js": ">1.34.0", + "react": ">0.13.0" + } + }, + "node_modules/react-query": { + "version": "3.39.3", + "resolved": "https://registry.npmjs.org/react-query/-/react-query-3.39.3.tgz", + "integrity": "sha512-nLfLz7GiohKTJDuT4us4X3h/8unOh+00MLb2yJoGTPjxKs2bc1iDhkNx2bd5MKklXnOD3NrVZ+J2UXujA5In4g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.5.5", + "broadcast-channel": "^3.4.1", + "match-sorter": "^6.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regl": { + "name": "@plotly/regl", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@plotly/regl/-/regl-2.1.2.tgz", + "integrity": "sha512-Mdk+vUACbQvjd0m/1JJjOOafmkp/EpmHjISsopEz5Av44CBq7rPC05HHNbYGKVyNUF2zmEoBS/TT0pd0SPFFyw==", + "license": "MIT" + }, + "node_modules/regl-error2d": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/regl-error2d/-/regl-error2d-2.0.12.tgz", + "integrity": "sha512-r7BUprZoPO9AbyqM5qlJesrSRkl+hZnVKWKsVp7YhOl/3RIpi4UDGASGJY0puQ96u5fBYw/OlqV24IGcgJ0McA==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "color-normalize": "^1.5.0", + "flatten-vertex-data": "^1.0.2", + "object-assign": "^4.1.1", + "pick-by-alias": "^1.2.0", + "to-float32": "^1.1.0", + "update-diff": "^1.1.0" + } + }, + "node_modules/regl-line2d": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/regl-line2d/-/regl-line2d-3.1.3.tgz", + "integrity": "sha512-fkgzW+tTn4QUQLpFKsUIE0sgWdCmXAM3ctXcCgoGBZTSX5FE2A0M7aynz7nrZT5baaftLrk9te54B+MEq4QcSA==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "array-find-index": "^1.0.2", + "array-normalize": "^1.1.4", + "color-normalize": "^1.5.0", + "earcut": "^2.1.5", + "es6-weak-map": "^2.0.3", + "flatten-vertex-data": "^1.0.2", + "object-assign": "^4.1.1", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0", + "to-float32": "^1.1.0" + } + }, + "node_modules/regl-scatter2d": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/regl-scatter2d/-/regl-scatter2d-3.3.1.tgz", + "integrity": "sha512-seOmMIVwaCwemSYz/y4WE0dbSO9svNFSqtTh5RE57I7PjGo3tcUYKtH0MTSoshcAsreoqN8HoCtnn8wfHXXfKQ==", + "license": "MIT", + "dependencies": { + "@plotly/point-cluster": "^3.1.9", + "array-range": "^1.0.1", + "array-rearrange": "^2.2.2", + "clamp": "^1.0.1", + "color-id": "^1.1.0", + "color-normalize": "^1.5.0", + "color-rgba": "^2.1.1", + "flatten-vertex-data": "^1.0.2", + "glslify": "^7.0.0", + "is-iexplorer": "^1.0.0", + "object-assign": "^4.1.1", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0", + "to-float32": "^1.1.0", + "update-diff": "^1.1.0" + } + }, + "node_modules/regl-splom": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/regl-splom/-/regl-splom-1.0.14.tgz", + "integrity": "sha512-OiLqjmPRYbd7kDlHC6/zDf6L8lxgDC65BhC8JirhP4ykrK4x22ZyS+BnY8EUinXKDeMgmpRwCvUmk7BK4Nweuw==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "array-range": "^1.0.1", + "color-alpha": "^1.0.4", + "flatten-vertex-data": "^1.0.2", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0", + "raf": "^3.4.1", + "regl-scatter2d": "^3.2.3" + } + }, + "node_modules/remove-accents": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/right-now": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/right-now/-/right-now-1.0.0.tgz", + "integrity": "sha512-DA8+YS+sMIVpbsuKgy+Z67L9Lxb1p05mNxRpDPNksPDEFir4vmBlUtuN9jkTGn9YMMdlBuK7XQgFiz6ws+yhSg==", + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "license": "ISC" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/schema-utils": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shallow-copy": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/shallow-copy/-/shallow-copy-0.0.1.tgz", + "integrity": "sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw==", + "license": "MIT" + }, + "node_modules/sharp": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", + "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.4", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.3", + "@img/sharp-darwin-x64": "0.34.3", + "@img/sharp-libvips-darwin-arm64": "1.2.0", + "@img/sharp-libvips-darwin-x64": "1.2.0", + "@img/sharp-libvips-linux-arm": "1.2.0", + "@img/sharp-libvips-linux-arm64": "1.2.0", + "@img/sharp-libvips-linux-ppc64": "1.2.0", + "@img/sharp-libvips-linux-s390x": "1.2.0", + "@img/sharp-libvips-linux-x64": "1.2.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", + "@img/sharp-libvips-linuxmusl-x64": "1.2.0", + "@img/sharp-linux-arm": "0.34.3", + "@img/sharp-linux-arm64": "0.34.3", + "@img/sharp-linux-ppc64": "0.34.3", + "@img/sharp-linux-s390x": "0.34.3", + "@img/sharp-linux-x64": "0.34.3", + "@img/sharp-linuxmusl-arm64": "0.34.3", + "@img/sharp-linuxmusl-x64": "0.34.3", + "@img/sharp-wasm32": "0.34.3", + "@img/sharp-win32-arm64": "0.34.3", + "@img/sharp-win32-ia32": "0.34.3", + "@img/sharp-win32-x64": "0.34.3" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/signum": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/signum/-/signum-1.0.0.tgz", + "integrity": "sha512-yodFGwcyt59XRh7w5W3jPcIQb3Bwi21suEfT7MAWnBX3iCdklJpgDgvGT9o04UonglZN5SNMfJFkHIR/jO8GHw==", + "license": "MIT" + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stack-trace": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.9.tgz", + "integrity": "sha512-vjUc6sfgtgY0dxCdnc40mK6Oftjo9+2K8H/NG81TMhgL392FtiPA9tn9RLyTxXmTLPJPjF3VyzFp6bsWFLisMQ==", + "engines": { + "node": "*" + } + }, + "node_modules/static-eval": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz", + "integrity": "sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA==", + "license": "MIT", + "dependencies": { + "escodegen": "^2.1.0" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/stream-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz", + "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==", + "license": "MIT", + "dependencies": { + "debug": "2" + } + }, + "node_modules/stream-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/stream-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/string-split-by": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string-split-by/-/string-split-by-1.0.0.tgz", + "integrity": "sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==", + "license": "MIT", + "dependencies": { + "parenthesis": "^3.1.5" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strongly-connected-components": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strongly-connected-components/-/strongly-connected-components-1.0.1.tgz", + "integrity": "sha512-i0TFx4wPcO0FwX+4RkLJi1MxmcTv90jNZgxMu9XRnMXMeFUY1VJlIoXpZunPUvUUqbCT1pg5PEkFqqpcaElNaA==", + "license": "MIT" + }, + "node_modules/style-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supercluster": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", + "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", + "license": "ISC", + "dependencies": { + "kdbush": "^3.0.0" + } + }, + "node_modules/supercluster/node_modules/kdbush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", + "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", + "license": "ISC" + }, + "node_modules/superscript-text": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/superscript-text/-/superscript-text-1.0.0.tgz", + "integrity": "sha512-gwu8l5MtRZ6koO0icVTlmN5pm7Dhh1+Xpe9O4x6ObMAsW+3jPbW14d1DsBq1F4wiI+WOFjXF35pslgec/G8yCQ==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-arc-to-cubic-bezier": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", + "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==", + "license": "ISC" + }, + "node_modules/svg-path-bounds": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/svg-path-bounds/-/svg-path-bounds-1.0.2.tgz", + "integrity": "sha512-H4/uAgLWrppIC0kHsb2/dWUYSmb4GE5UqH06uqWBcg6LBjX2fu0A8+JrO2/FJPZiSsNOKZAhyFFgsLTdYUvSqQ==", + "license": "MIT", + "dependencies": { + "abs-svg-path": "^0.1.1", + "is-svg-path": "^1.0.1", + "normalize-svg-path": "^1.0.0", + "parse-svg-path": "^0.1.2" + } + }, + "node_modules/svg-path-bounds/node_modules/normalize-svg-path": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz", + "integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==", + "license": "MIT", + "dependencies": { + "svg-arc-to-cubic-bezier": "^3.0.0" + } + }, + "node_modules/svg-path-sdf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/svg-path-sdf/-/svg-path-sdf-1.1.3.tgz", + "integrity": "sha512-vJJjVq/R5lSr2KLfVXVAStktfcfa1pNFjFOgyJnzZFXlO/fDZ5DmM8FpnSKKzLPfEYTVeXuVBTHF296TpxuJVg==", + "license": "MIT", + "dependencies": { + "bitmap-sdf": "^1.0.0", + "draw-svg-path": "^1.0.0", + "is-svg-path": "^1.0.1", + "parse-svg-path": "^0.1.2", + "svg-path-bounds": "^1.0.1" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/tapable": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", + "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/terser": { + "version": "5.43.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", + "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.14.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", + "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT", + "peer": true + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", + "license": "ISC" + }, + "node_modules/to-float32": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/to-float32/-/to-float32-1.1.0.tgz", + "integrity": "sha512-keDnAusn/vc+R3iEiSDw8TOF7gPiTLdK1ArvWtYbJQiVfmRg6i/CAvbKq3uIS0vWroAC7ZecN3DjQKw3aSklUg==", + "license": "MIT" + }, + "node_modules/to-px": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-px/-/to-px-1.0.1.tgz", + "integrity": "sha512-2y3LjBeIZYL19e5gczp14/uRWFDtDUErJPVN3VU9a7SJO+RjGRtYR47aMN2bZgGlxvW4ZcEz2ddUPVHXcMfuXw==", + "license": "MIT", + "dependencies": { + "parse-unit": "^1.0.1" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/topojson-client/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typedarray-pool": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/typedarray-pool/-/typedarray-pool-1.2.0.tgz", + "integrity": "sha512-YTSQbzX43yvtpfRtIDAYygoYtgT+Rpjuxy9iOpczrjpXLgGoyG7aS5USJXV2d3nn8uHTeb9rXDvzS27zUg5KYQ==", + "license": "MIT", + "dependencies": { + "bit-twiddle": "^1.0.0", + "dup": "^1.0.0" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unload": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz", + "integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.6.2", + "detect-node": "^2.0.4" + } + }, + "node_modules/unquote": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", + "integrity": "sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==", + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/update-diff": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-diff/-/update-diff-1.1.0.tgz", + "integrity": "sha512-rCiBPiHxZwT4+sBhEbChzpO5hYHjm91kScWgdHf4Qeafs6Ba7MBl+d9GlGv72bcTZQO0sLmtQS1pHSWoCLtN/A==", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vt-pbf": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "0.1.0", + "@mapbox/vector-tile": "^1.3.1", + "pbf": "^3.2.1" + } + }, + "node_modules/watchpack": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", + "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "license": "MIT", + "peer": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/weak-map": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/weak-map/-/weak-map-1.0.8.tgz", + "integrity": "sha512-lNR9aAefbGPpHO7AEnY0hCFjz1eTkWCXYvkTRrTHs9qv8zJp+SkVYpzfLIFXQQiG3tVvbNFQgVg2bQS8YGgxyw==", + "license": "Apache-2.0" + }, + "node_modules/webgl-context": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/webgl-context/-/webgl-context-2.2.0.tgz", + "integrity": "sha512-q/fGIivtqTT7PEoF07axFIlHNk/XCPaYpq64btnepopSWvKNFkoORlQYgqDigBIuGA1ExnFd/GnSUnBNEPQY7Q==", + "license": "MIT", + "dependencies": { + "get-canvas-context": "^1.0.1" + } + }, + "node_modules/webpack": { + "version": "5.101.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.1.tgz", + "integrity": "sha512-rHY3vHXRbkSfhG6fH8zYQdth/BtDgXXuR2pHF++1f/EBkI8zkgM5XWfsC3BvOoW9pr1CvZ1qQCxhCEsbNgT50g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.24.0", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.3", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.2", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.11", + "watchpack": "^2.4.1", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/world-calendars": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/world-calendars/-/world-calendars-1.0.4.tgz", + "integrity": "sha512-VGRnLJS+xJmGDPodgJRnGIDwGu0s+Cr9V2HB3EzlDZ5n0qb8h5SJtGUEkjrphZYAglEiXZ6kiXdmk0H/h/uu/w==", + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c80025f --- /dev/null +++ b/frontend/package.json @@ -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" +} diff --git a/frontend/pages/_app.tsx b/frontend/pages/_app.tsx new file mode 100644 index 0000000..f9da444 --- /dev/null +++ b/frontend/pages/_app.tsx @@ -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 ( + + + + ) +} \ No newline at end of file diff --git a/frontend/pages/_document.tsx b/frontend/pages/_document.tsx new file mode 100644 index 0000000..3c63133 --- /dev/null +++ b/frontend/pages/_document.tsx @@ -0,0 +1,15 @@ +import { Html, Head, Main, NextScript } from 'next/document'; + +export default function Document() { + return ( + + + + + +
+ + + + ); +} diff --git a/frontend/pages/_error.tsx b/frontend/pages/_error.tsx new file mode 100644 index 0000000..b39d9d5 --- /dev/null +++ b/frontend/pages/_error.tsx @@ -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 ( + +
+

{statusCode}

+

{title}

+

{message}

+ + ํ™ˆ์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ + +
+
+ ); +} + +ErrorPage.getInitialProps = ({ res, err }: NextPageContext) => { + const statusCode = res ? res.statusCode : err ? err.statusCode ?? 500 : 404; + return { statusCode }; +}; + +export default ErrorPage; diff --git a/frontend/pages/database.tsx b/frontend/pages/database.tsx new file mode 100644 index 0000000..7b3433c --- /dev/null +++ b/frontend/pages/database.tsx @@ -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: () => ( +
+
+
+
+
+
+
+
+ ), +}); + +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 ( + +
+ + + +
+ + {tab==='stats' && } + + {tab==='etf' && ( +
+

์ €์žฅ๋œ ETF ์Šค๋ƒ…์ƒท

+ {etf.loading ? ( +

๋กœ๋”ฉ ์ค‘...

+ ) : etf.items.length===0 ? ( +

์ €์žฅ๋œ ์Šค๋ƒ…์ƒท์ด ์—†์Šต๋‹ˆ๋‹ค.

+ ) : ( +
+ + + + + + + + + + + + {etf.items.map((s:any) => ( + + + + + + + + ))} + +
Snapshot IDTickerDateSourceHoldings
{s.id}{s.ticker}{new Date(s.snapshot_date).toLocaleDateString('ko-KR')}{s.source || '-'}{s.holdings_count}
+
+ )} +
+ )} + + {tab==='financial' && ( +
+

์ €์žฅ๋œ ์žฌ๋ฌด ๋ ˆ์ฝ”๋“œ

+ {fin.loading ? ( +

๋กœ๋”ฉ ์ค‘...

+ ) : fin.items.length===0 ? ( +

๋ ˆ์ฝ”๋“œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

+ ) : ( +
+ + + + + + + + + + + + + {fin.items.map((r:any) => ( + + + + + + + + + ))} + +
TickerPeriodTypeRevenueNet IncomeSource
{r.ticker}{new Date(r.period_date).toLocaleDateString('ko-KR')}{r.period_type}{r.revenue?.toLocaleString() ?? '-'}{r.net_income?.toLocaleString() ?? '-'}{r.data_source}
+
+ )} +
+ )} +
+ ); +}; + +export default DatabasePage; \ No newline at end of file diff --git a/frontend/pages/errors.tsx b/frontend/pages/errors.tsx new file mode 100644 index 0000000..4902cb7 --- /dev/null +++ b/frontend/pages/errors.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import Layout from '../components/Layout'; +import ErrorLogViewer from '../components/ErrorLogViewer'; + +const ErrorsPage: React.FC = () => { + return ( + + + + ); +}; + +export default ErrorsPage; \ No newline at end of file diff --git a/frontend/pages/fred.tsx b/frontend/pages/fred.tsx new file mode 100644 index 0000000..f9a7a8e --- /dev/null +++ b/frontend/pages/fred.tsx @@ -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 = ({ stats, isLoading }) => { + if (isLoading) { + return ( +
+
+
+
+
+
+ ); + } + + 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 ( +
+

๐Ÿ“Š FRED API Daily Quota

+ +
+
+
Used Today
+
+ {stats.used_today} +
+
+ +
+
Remaining
+
+ {stats.remaining_today} +
+
+ +
+
Daily Limit
+
+ {stats.daily_limit} +
+
+ +
+
Usage %
+
+ {stats.usage_percentage.toFixed(1)}% +
+
+
+ + {/* Progress Bar */} +
+
+ API Usage Progress + {stats.used_today} / {stats.daily_limit} +
+
+
+
+
+ + {/* Status */} +
+
+ + {stats.can_make_requests ? 'API Available' : 'Daily Limit Reached'} + +
+
+ ); +}; + +interface CacheStatsProps { + stats: FredUsageStats; +} + +const CacheStats: React.FC = ({ stats }) => { + return ( +
+

๐Ÿ’พ Database Cache Statistics

+ +
+
+
Cached Series
+
+ {stats.cache_stats.cached_series.toLocaleString()} +
+
Economic data series
+
+ +
+
Cached Observations
+
+ {stats.cache_stats.cached_observations.toLocaleString()} +
+
Historical data points
+
+ +
+
Cache Duration
+
+ {stats.cache_stats.cache_duration_hours}h +
+
Auto-refresh period
+
+
+ +
+

๐Ÿ”ง Proxy Information

+
+
+ Mode: + {stats.proxy_info.mode} +
+
+ Smart Caching: + {stats.proxy_info.smart_caching ? 'Enabled' : 'Disabled'} +
+
+ Permanent Storage: + {stats.proxy_info.permanent_storage ? 'Yes' : 'No'} +
+
+ Supported Endpoints: + {stats.proxy_info.supported_endpoints} +
+
+
+
+ ); +}; + +interface EndpointStatsProps { + stats: FredUsageStats; +} + +const EndpointStats: React.FC = ({ stats }) => { + const topEndpoints = stats.endpoint_stats.slice(0, 10); + + return ( +
+

๐Ÿ”ฅ Most Used Endpoints

+ + {topEndpoints.length > 0 ? ( +
+ {topEndpoints.map((endpoint, index) => ( +
+
+
+ {index + 1} +
+ + {endpoint.endpoint} + +
+
+
{endpoint.call_count}
+
calls
+
+
+ ))} +
+ ) : ( +
+ No endpoint usage data available +
+ )} +
+ ); +}; + +interface PopularSeriesProps { + series: FredSeriesData[]; + isLoading: boolean; + onSelectSeries: (seriesId: string) => void; +} + +const PopularSeries: React.FC = ({ series, isLoading, onSelectSeries }) => { + if (isLoading) { + return ( +
+

๐Ÿ“ˆ Popular Economic Indicators

+
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+ ))} +
+
+ ); + } + + return ( +
+

๐Ÿ“ˆ Popular Economic Indicators

+ +
+ {series.map((seriesData) => ( + + ))} +
+
+ ); +}; + +interface SeriesSearchProps { + onSelectSeries: (seriesId: string) => void; +} + +const SeriesSearch: React.FC = ({ onSelectSeries }) => { + const [searchTerm, setSearchTerm] = useState(''); + const [searchResults, setSearchResults] = useState([]); + 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 ( +
+

๐Ÿ” Search Economic Data

+ +
+ 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" + /> + +
+ + {searchResults.length > 0 && ( +
+ {searchResults.map((seriesData) => ( + + ))} +
+ )} + + {searchTerm && searchResults.length === 0 && !isSearching && ( +
+ No results found for "{searchTerm}" +
+ )} +
+ ); +}; + +interface SeriesDataDisplayProps { + seriesId: string; + observations: FredObservationsData | null; + isLoading: boolean; + onClose: () => void; +} + +const SeriesDataDisplay: React.FC = ({ seriesId, observations, isLoading, onClose }) => { + if (isLoading) { + return ( +
+
+
+ +
+
+ {[...Array(10)].map((_, i) => ( +
+
+
+
+ ))} +
+
+ ); + } + + if (!observations) return null; + + return ( +
+
+

+ ๐Ÿ“Š {seriesId} + {observations.cached && ( + From Cache + )} +

+ +
+ +
+ {observations.count} observations โ€ข Last 20 shown + {observations.cached_at && ( + โ€ข Cached: {new Date(observations.cached_at).toLocaleString()} + )} +
+ +
+ + + + + + + + + {observations.observations.slice(0, 20).map((obs, index) => ( + + + + + ))} + +
DateValue
{obs.date} + {obs.value === '.' ? 'N/A' : parseFloat(obs.value).toLocaleString()} +
+
+
+ ); +}; + +const FredPage: React.FC = () => { + const [stats, setStats] = useState(null); + const [popularSeries, setPopularSeries] = useState([]); + const [selectedSeriesId, setSelectedSeriesId] = useState(null); + const [seriesObservations, setSeriesObservations] = useState(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 ( + +
+
+

๐Ÿฆ FRED Economic Data

+
+ Federal Reserve Economic Data (FRED) API Access +
+
+ + {/* Quota Display */} + {stats && } + +
+ {/* Left Column */} +
+ {/* Popular Series */} + + + {/* Search */} + +
+ + {/* Right Column */} +
+ {/* Cache Stats */} + {stats && } + + {/* Endpoint Stats */} + {stats && } +
+
+ + {/* Series Data Display */} + {selectedSeriesId && ( + + )} +
+
+ ); +}; + +export default FredPage; \ No newline at end of file diff --git a/frontend/pages/index.tsx b/frontend/pages/index.tsx new file mode 100644 index 0000000..df9d6c1 --- /dev/null +++ b/frontend/pages/index.tsx @@ -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(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 ( + +
+ {/* ํ™˜์˜ ์„น์…˜ */} +
+
+
+

Stock Oracle์— ์˜ค์‹  ๊ฒƒ์„ ํ™˜์˜ํ•ฉ๋‹ˆ๋‹ค

+

+ ์‹ค์‹œ๊ฐ„ ์ฃผ์‹ ๋ฐ์ดํ„ฐ์™€ ์žฌ๋ฌด ๋ถ„์„ ํ”Œ๋žซํผ +

+

+ SEC ์‹ค์ œ ๋ฐ์ดํ„ฐ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ํ•œ ์ •ํ™•ํ•œ ์žฌ๋ฌด ๋ถ„์„์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. +

+
+
+ +
+
+
+ + {/* ๋น ๋ฅธ ํ†ต๊ณ„ */} +
+ {quickStats.map((stat) => { + const Icon = stat.icon; + return ( +
+
+
+ +
+
+

{stat.name}

+
+ {loading ? ( + + ) : ( + {formatNumber(stat.value)} + )} +
+
+
+

{stat.description}

+
+ ); + })} +
+ + {/* ๋ฐ์ดํ„ฐ ํ’ˆ์งˆ ๊ฐœ์š” */} + {stats && ( +
+ {/* ์žฌ๋ฌด ๋ฐ์ดํ„ฐ ํ’ˆ์งˆ */} +
+

+ + ์žฌ๋ฌด ๋ฐ์ดํ„ฐ ํ’ˆ์งˆ +

+ +
+
+
+ ์‹ค์ œ ๋ฐ์ดํ„ฐ ๋น„์œจ + + {((stats.financial_data.real_data / stats.financial_data.total_records) * 100).toFixed(1)}% + +
+
+
+
+
+ +
+
+

{formatNumber(stats.financial_data.real_data)}

+

์‹ค์ œ ๋ฐ์ดํ„ฐ

+
+
+

{formatNumber(stats.financial_data.estimated_data)}

+

์ถ”์ • ๋ฐ์ดํ„ฐ

+
+
+
+
+ + {/* ์ปค๋ฒ„๋ฆฌ์ง€ ์ •๋ณด */} +
+

+ + ๋ฐ์ดํ„ฐ ์ปค๋ฒ„๋ฆฌ์ง€ +

+ +
+
+ ์žฌ๋ฌด ๋ฐ์ดํ„ฐ ๋ณด์œ  ํšŒ์‚ฌ + + {stats.companies.with_financial_data} / {stats.companies.total} + +
+ +
+ ์ฃผ๊ฐ€ ๋ฐ์ดํ„ฐ ๋ณด์œ  ํšŒ์‚ฌ + + {stats.companies.with_price_data} / {stats.companies.total} + +
+ +
+

์ฃผ๊ฐ€ ๋ฐ์ดํ„ฐ ๋ณด์œ  ์ข…๋ชฉ

+
+ {stats.price_data.tickers.slice(0, 8).map((ticker) => ( + + {ticker} + + ))} + {stats.price_data.tickers.length > 8 && ( + + +{stats.price_data.tickers.length - 8}๊ฐœ ๋” + + )} +
+
+
+
+
+ )} + + {/* ๋น ๋ฅธ ์•ก์…˜ */} + + + {/* ์‹œ์Šคํ…œ ์ƒํƒœ */} +
+

์‹œ์Šคํ…œ ์ƒํƒœ

+ +
+
+
+
+ API ์„œ๋ฒ„ +
+ ์ •์ƒ +
+ +
+
+
+ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค +
+ ์—ฐ๊ฒฐ๋จ +
+ +
+
+
+ ์™ธ๋ถ€ API +
+ ํ™œ์„ฑ +
+ +
+
+
+ FRED API +
+ ์—ฐ๊ฒฐ๋จ +
+
+
+
+
+ ); +}; + +export default Dashboard; \ No newline at end of file diff --git a/frontend/pages/logs.tsx b/frontend/pages/logs.tsx new file mode 100644 index 0000000..0640c04 --- /dev/null +++ b/frontend/pages/logs.tsx @@ -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: () => ( +
+
+
+

๋กœ๊ทธ ํŽ˜์ด์ง€ ๋กœ๋”ฉ ์ค‘...

+
+
+ ) + } +); + +// This component will never be server-side rendered +const LogsPage: React.FC = () => { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return ( + <> + + ์‹œ์Šคํ…œ ๋กœ๊ทธ - Stock Oracle + + +
+
+
+

์ดˆ๊ธฐํ™” ์ค‘...

+
+
+ + ); + } + + return ( + <> + + ์‹œ์Šคํ…œ ๋กœ๊ทธ - Stock Oracle + + + + + ); +}; + +export default LogsPage; \ No newline at end of file diff --git a/frontend/pages/query.tsx b/frontend/pages/query.tsx new file mode 100644 index 0000000..44eada4 --- /dev/null +++ b/frontend/pages/query.tsx @@ -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: () => ( +
+
+
+
+
+
+
+ ), +}); + +const QueryPage: React.FC = () => { + return ( + + + + ); +}; + +export default QueryPage; \ No newline at end of file diff --git a/frontend/pages/requests.tsx b/frontend/pages/requests.tsx new file mode 100644 index 0000000..b6f48ff --- /dev/null +++ b/frontend/pages/requests.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import Layout from '@/components/Layout'; +import RequestLogViewer from '../components/RequestLogViewer'; + +const RequestsPage: React.FC = () => { + return ( + + + + ); +}; + +export default RequestsPage; diff --git a/frontend/pages/settings.tsx b/frontend/pages/settings.tsx new file mode 100644 index 0000000..c9d915a --- /dev/null +++ b/frontend/pages/settings.tsx @@ -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 ( + +
+ {/* ํ—ค๋” */} +
+

+ + ์‹œ์Šคํ…œ ์„ค์ • +

+
+ + {/* ์„ฑ๊ณต ๋ฉ”์‹œ์ง€ */} + {savedMessage && ( +
+
+ +

{savedMessage}

+
+
+ )} + + {/* API ์„ค์ • */} +
+

API ์„ค์ •

+ +
+
+ + 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" + /> +

+ Stock Oracle API ์„œ๋ฒ„์˜ ์ฃผ์†Œ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”. +

+
+ +
+ setAutoRefresh(e.target.checked)} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + /> + +
+ + {autoRefresh && ( +
+ + +
+ )} +
+
+ + {/* ์‚ฌ์šฉ์ž ์ธํ„ฐํŽ˜์ด์Šค ์„ค์ • */} +
+

์‚ฌ์šฉ์ž ์ธํ„ฐํŽ˜์ด์Šค

+ +
+
+ + +
+ +
+ setNotifications(e.target.checked)} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + /> + +
+
+
+ + {/* ๋ฐ์ดํ„ฐ ์„ค์ • */} +
+

๋ฐ์ดํ„ฐ ์„ค์ •

+ +
+
+
+ +
+

๋ฐ์ดํ„ฐ ์ƒˆ๋กœ๊ณ ์นจ ์ฃผ์˜์‚ฌํ•ญ

+

+ ๊ฐ•์ œ ์ƒˆ๋กœ๊ณ ์นจ์€ ์™ธ๋ถ€ API ํ˜ธ์ถœ์„ ๋ฐœ์ƒ์‹œ์ผœ ์‘๋‹ต ์‹œ๊ฐ„์ด ๊ธธ์–ด์งˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + ์ผ๋ฐ˜์ ์œผ๋กœ ์บ์‹œ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค. +

+
+
+
+ +
+
+ +
+

์‹ค์ œ vs ์ถ”์ • ๋ฐ์ดํ„ฐ

+

+ ์‹ค์ œ ๋ฐ์ดํ„ฐ๋Š” SEC EDGAR์—์„œ ๊ฐ€์ ธ์˜จ ์ •ํ™•ํ•œ ์žฌ๋ฌด ๋ฐ์ดํ„ฐ์ž…๋‹ˆ๋‹ค. + ์ถ”์ • ๋ฐ์ดํ„ฐ๋Š” ๊ธฐ์กด ์‹œ์Šคํ…œ์—์„œ ์ƒ์„ฑ๋œ ์ž„์‹œ ๋ฐ์ดํ„ฐ์ž…๋‹ˆ๋‹ค. +

+
+
+
+
+
+ + {/* ์‹œ์Šคํ…œ ์ •๋ณด */} +
+

์‹œ์Šคํ…œ ์ •๋ณด

+ +
+
+

๋ฒ„์ „

+

Stock Oracle v1.0.0

+
+
+

๋นŒ๋“œ ๋‚ ์งœ

+

{new Date().toLocaleDateString('ko-KR')}

+
+
+

ํ˜„์žฌ API URL

+

{apiUrl}

+
+
+

๋ธŒ๋ผ์šฐ์ € ์ง€์›

+

Chrome, Firefox, Safari, Edge

+
+
+
+ + {/* ์•ก์…˜ ๋ฒ„ํŠผ */} +
+ + + +
+
+
+ ); +}; + +export default SettingsPage; \ No newline at end of file diff --git a/frontend/pages/stock.tsx b/frontend/pages/stock.tsx new file mode 100644 index 0000000..6b168fa --- /dev/null +++ b/frontend/pages/stock.tsx @@ -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 = () => { + const [ticker, setTicker] = useState(''); + const [searchTicker, setSearchTicker] = useState(''); + const [company, setCompany] = useState(null); + const [financialData, setFinancialData] = useState([]); + const [priceData, setPriceData] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [showAllFinancialData, setShowAllFinancialData] = useState(false); + const [periodType, setPeriodType] = useState<'quarterly' | 'annual' | 'all'>('all'); + const [chartPeriod, setChartPeriod] = useState('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 ( + +
+ {/* ๊ฒ€์ƒ‰ ์„น์…˜ */} +
+

+ + ์ข…๋ชฉ ์ƒ์„ธ ์กฐํšŒ +

+ +
+
+ + 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" + /> +
+ +
+ + +
+ +
+ +
+
+ + {error && ( +
+ + {error} +
+ )} +
+ + {/* Professional Stock Analysis Dashboard */} + {company && ( +
+ {/* Premium Company Header */} +
+
+
+
+
+ +
+
+

{company.name}

+
+ {company.ticker} + {company.sector && ( + โ€ข {company.sector} + )} +
+
+
+ + {/* Live Price Display */} + {latestPrice && ( +
+
+ {formatCurrency(latestPrice.close)} +
+
= 0 + ? 'bg-green-500/20 text-green-300' + : 'bg-red-500/20 text-red-300' + }`}> + {priceChange.change >= 0 ? : } + + {priceChange.change >= 0 ? '+' : ''}{priceChange.change.toFixed(2)} + ({priceChange.changePercent >= 0 ? '+' : ''}{priceChange.changePercent.toFixed(2)}%) + +
+
+ )} +
+ + {/* Market Status */} +
+
+
+ Live Data +
+ {latestPrice && ( +
+ Last Updated: {formatDate(latestPrice.date)} +
+ )} +
+
+
+ + {/* Key Performance Indicators Grid */} + {(latestPrice || latestFinancials) && ( +
+ {latestPrice && ( + <> +
+
+ + 52W High +
+
{formatCurrency(weekRange.high)}
+
+ +
+
+ + 52W Low +
+
{formatCurrency(weekRange.low)}
+
+ +
+
+ + Volume +
+
{formatNumber(latestPrice.volume)}
+
+ + )} + + {latestFinancials && ( + <> +
+
+ + P/E Ratio +
+
+ {latestFinancials.eps && latestPrice ? + (latestPrice.close / latestFinancials.eps).toFixed(2) : 'N/A' + } +
+
+ +
+
+ + ROE +
+
{formatPercentage(latestFinancials.roe)}
+
+ +
+
+ + Margin +
+
{formatPercentage(latestFinancials.net_margin)}
+
+ + )} +
+ )} + + {/* Main Dashboard Grid */} +
+ + {/* Chart Section - Takes 2/3 width */} +
+ {/* Stock Chart */} +
+
+
+
+ +
+

์ฃผ๊ฐ€ ์ฐจํŠธ

+
+ +
+ {/* Period Selector */} +
+ {chartPeriods.map((period) => ( + + ))} +
+
+
+ + {/* Chart Display */} +
+ 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 }} + /> +
+
+ + {/* Financial Trend Chart */} + {financialData.length > 1 && ( +
+
+
+ +
+

์žฌ๋ฌด ํŠธ๋ Œ๋“œ

+
+
+ { + 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 }} + /> +
+
+ )} +
+ + {/* Sidebar - Takes 1/3 width */} +
+ {/* Company Overview */} + {company.business_description && ( +
+
+
+ +
+

ํšŒ์‚ฌ ๊ฐœ์š”

+
+

{company.business_description}

+
+ )} + + {/* Key Financial Metrics */} + {latestFinancials && ( +
+
+
+ +
+

์ฃผ์š” ์žฌ๋ฌด ์ง€ํ‘œ

+
+ +
+
+ ๋งค์ถœ์•ก + {formatCurrency(latestFinancials.revenue)} +
+
+ ์ˆœ์ด์ต + {formatCurrency(latestFinancials.net_income)} +
+
+ ์ด ์ž์‚ฐ + {formatCurrency(latestFinancials.total_assets)} +
+
+ EPS + + {latestFinancials.eps ? `$${latestFinancials.eps.toFixed(2)}` : 'N/A'} + +
+
+
+ )} + + {/* Price Statistics */} + {latestPrice && ( +
+
+
+ +
+

์ฃผ๊ฐ€ ์ •๋ณด

+
+ +
+
+ ์‹œ๊ฐ€ + {formatCurrency(latestPrice.open)} +
+
+ ๊ณ ๊ฐ€ + {formatCurrency(latestPrice.high)} +
+
+ ์ €๊ฐ€ + {formatCurrency(latestPrice.low)} +
+
+ ๊ฑฐ๋ž˜๋Ÿ‰ + {formatNumber(latestPrice.volume)} +
+
+
+ )} + + {/* Financial Ratios */} + {latestFinancials && ( +
+
+
+ +
+

์žฌ๋ฌด ๋น„์œจ

+
+ +
+
+ ROA + {formatPercentage(latestFinancials.roa)} +
+
+ ์ˆœ์ด์ต๋ฅ  + {formatPercentage(latestFinancials.net_margin)} +
+
+ ๋ถ€์ฑ„๋น„์œจ + + {latestFinancials.total_debt && latestFinancials.total_assets ? + formatPercentage((latestFinancials.total_debt / latestFinancials.total_assets) * 100) : 'N/A' + } + +
+
+
+ )} +
+
+ + {/* Financial Data Table */} + {financialData && financialData.length > 0 && ( +
+
+
+
+ +
+

์žฌ๋ฌด ๋ฐ์ดํ„ฐ ํžˆ์Šคํ† ๋ฆฌ

+ + {financialData.length}๊ฐœ ๊ธฐ๋ก + +
+ + {financialData.length > 8 && ( + + )} +
+ +
+ + + + + + + + + + + + + + {displayedFinancialData.map((data, index) => ( + + + + + + + + + + ))} + +
๋‚ ์งœ์œ ํ˜•๋งค์ถœ์•ก์ˆœ์ด์ตEPSROE์ˆœ์ด์ต๋ฅ 
+ {formatDate(data.period_date)} + + + {data.period_type === 'quarterly' ? '๋ถ„๊ธฐ' : '์—ฐ๊ฐ„'} + + + {formatCurrency(data.revenue)} + + {formatCurrency(data.net_income)} + + {data.eps ? `$${data.eps.toFixed(2)}` : 'N/A'} + + {formatPercentage(data.roe)} + + {formatPercentage(data.net_margin)} +
+
+
+ )} +
+ )} + + {/* ๋‰ด์Šค ๋ฐ ์†Œ์…œ ๋ฏธ๋””์–ด ์„น์…˜ */} + {ticker && ( + + )} + + {/* ๋ฐ์ดํ„ฐ ์—†์Œ ๋ฉ”์‹œ์ง€ */} + {ticker && !loading && !company && (!financialData || financialData.length === 0) && (!priceData || priceData.length === 0) && ( +
+ +

๋ฐ์ดํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค

+

+ "{ticker}" ์ข…๋ชฉ์— ๋Œ€ํ•œ ์ €์žฅ๋œ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ์ข…๋ชฉ ์ฝ”๋“œ๋ฅผ ์‹œ๋„ํ•ด๋ณด์„ธ์š”. +

+
+ )} +
+
+ ); +}; + +export default StockDetailPage; \ No newline at end of file diff --git a/frontend/pages/test-fred.tsx b/frontend/pages/test-fred.tsx new file mode 100644 index 0000000..dba706d --- /dev/null +++ b/frontend/pages/test-fred.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + +
+

๐Ÿงช FRED API Test

+ +
+ + + + + +
+ + {loading && ( +
+
+

Testing API...

+
+ )} + + {error && ( +
+

Error

+

{error}

+
+ )} + + {result && ( +
+

+ {result.type} - Result +

+
+              {JSON.stringify(result.data, null, 2)}
+            
+
+ )} + +
+

Debug Info

+

+ API Base URL: {process.env.NEXT_PUBLIC_API_URL || 'http://localhost:18001/api/v1'} +

+

+ Open browser dev tools to see network requests and console logs +

+
+
+
+ ); +}; + +export default TestFredPage; \ No newline at end of file diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..96bb01e --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/frontend/public/.gitkeep b/frontend/public/.gitkeep new file mode 100644 index 0000000..7a237b5 --- /dev/null +++ b/frontend/public/.gitkeep @@ -0,0 +1 @@ +# This file exists to ensure the public directory is tracked by git \ No newline at end of file diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d96d65e013075664e5f0a0b97f484fbcd6e994a4 GIT binary patch literal 5098 zcmbVQ_d6Tz+oe^ds8tj-Yu0K7L97xZ_EuZbsJ$gtsnOb+gxXr8)ZRkHRx?KJ8LMXP zy?uTDgZFx$>$;!w(|z6Jmvhb?sjaC@_K4vT9v&VULZD5#il0-A^cd2gGhF z#-4b1B-H-}K3;12Q#?HCbO`vhK0JLpgT(8JL26f@x8YjF^46A(g&I)CP1*Qw-FGhY zu`JL>m4xrC&}91kSL?qdL6QI&TBsbzTY)tg6T-C=tphXemjlM{f20a1zE=uZkNEk| z#*F|aCqE3CXf(L)&G0O;CxxKABn<+KikjvnZat;4M&^zKZ_S!7Hf0F#@0$C6JU1R1 z$Mp?5A-$&MlCe&|`-pvO=i=P5$w4l*?SK=86BFD#9Ug&Y@J%e;Z!SN_1V>JKA*y%B z?mApT5TYQC-rRTMNZYHdux`y0W{WUK43F^VM&KnWZi0C7iCC&C{=t1nXP?({@82P} zSWrL?N&&mBTk~s_4^^0La^Z|~$9qJhs>MspL*3!4p z9&Ec5_z&bLhX)dE-`!PV#E?nlQcSgD&7Wy^?Zq)#v2m5=1PS!Qzv^p?X@4h&J> zpR^cNI4&}21yeW4P0UA$ZaP!3yY7qS>L4k66$*7qKkr0_TFu=rP`V~BQx`!6NOhT0R^45aqpSP77(9su#Xl^P#hCX!!;`y_W42VeaVj^8xFZhCalSqTPTBqro4J1=`lflM`XEnH zk37n>#22(L?bI~oZDBgj1hh2C#A!}8o!wZak=_~;v<_6Jl#r}y+H}cTQvz5)1JK)) zY;FbZ)`ra$C+h%`Mi*-Nn1|RurPap^T9$kwInASV<+FI*+y=U>4XHtaQ>3iDDlyTc zhE@Fmv!i@t zyb~!1*73;h{FD(1a-*`@8zA$df{=|a6{T1wn`#c|bZbJ)ONEI4`R4YmL| zc|_3Sc63NsKLLv3!`Pko# zJDPAg8WK3kp10~R(oSnjZ^x92h9xgnHQZWvqW-q^d7kPN)EZgd?t=AuF=>=krYkVFs8Uq?c&%i_eTLdZ zc{%RQDJCCl{Cc@NG-lFX?_4Ew%+$wT!=iBY_mEZ-ZS;@~rd&uf=7tnvxJnWiGOTyt z?KNHPH+Hs7tUi>m;_!K@;-^_wEvNqmo{L|{f-D!h9D}Z-#5*Ep^XU-gWNk)t;ddLsZDiql9;>j2H7PdZ3H=A@S6$ zMvou+m-`ZhTit8;GD&5;F==f*eb|z$HJRdC7w^FAuw%dnQ-a#4#&_uNx>E8xscJB+gO) zt`Eds={T|*R`54N$HU#KtS&uk4+Ds}7|-F1K0Fnudlx~w=UR&KP=|^@4{>@M&h8ik z?p<|kO-PEK_Ys>Z3sYCz+6v|5hT|A!>t$+@U}HS#`u*EY`x4aSdydVGhsNx^SZUA1 z=@To@;>)2wwpVU2;jB(wbJ9$xW)a7Cb&9Ph{T#Ew=RXa1y~-6nUwoMVcCGZU#iY>Y znN}`C7n8JzP6Oxk@zB6~a)86FZm#U-1{5(4I&Q#JuT;*5jG@~na@e5ykOp|nS!+Zz zGYv$goHD6{^T!KHid}YOE&WbRj@`IC^QVG%C|6vdjepxKt&G6Nf$7THhc!J|X;W*5 z_P&bsZ7t<7GqJP{*VO~gDS^!EX00WL?XI18szOduf#u^6^F?2$S2Oz~>VW2px9pi> zdhC#m(6Wx~c4OAdkz>o`l-SZ0@2N6^X79#ngrLULNL!(I*N%66hVMOYEmaY|`C??H z_OJRmG!9$MVuDE;_yFIVZ>W4d))#B0yn2%nyO8D{eq8G6CxZU`5-N~Mv*ou=K96X_ zxIE+oyeGs-B&ZkL$0@E!6(ZD|f!+@`MB1>1M*opeIpdP`cap2rHtr=?fyJXu>O44g2KJC%gzGA+)G96*ZxyZ5xM;rZq+Z~azCKfbUSzZLfoO@Md1?1}KXa|*d zId1q3_q+Tx6z!@B@)uT(d3mS}47O)lS9$z2=UX5suD(lAOzV#IiGAAj6u6Ap>msVR zF4m))1pX=Ue-O+go5AiUq|D1Ct6#lT0{{d91wUQNOxcJYX)*T>-ZMrdIoNQ9%#}xF z6#*3=ViD^R9RB^xDJi7^at5jnDl0MoWsGRSL`AFxv_#L{%2B`XZqb8V(Op%gDU8(-t`&ej(u#PgkKJg32Azse znVInj_@Zl#X6^Ut+n-l!C$?8#y9mE6w_v*lq~1a?ei@%Y`z;5yBRIH|3IO=N&bns$ zcih`mXRbf@Dz|AHpqiS${x#R+sD8T;zg9_!?bX_j{Fmb)w%fkSRQ2Z~>S1;F#l$kV z)UcsVV}i6eJrIVc#}|qzd&32pU3Jo*@Rz_L3x$;g`Ub9U{ z^*p$12z%3YBmC+lUwF_Hx@u<^ap{nSp~w&k94#ikaXRX+CXL*#wpZ8HRc=olL-^DR z++%E=l3E>>^yo0PnU{E)jbC|85CllFsu&x(CJUS(^gNLzL~M~cuW`P?F|3#XVB=0C z+{cxnvEy31k-QibF}nb6b3LA*!TO|~#UNa77MH)#I&Z;~g|G6fIZvTL^J@AFZ`h8shbEJg9x$(d; zVn?nI^4kU~mNW1uUg^idlRbndO#s9QAeazMB) zrKJm4@HmGW3Fu^0`M<5lf#YRND6ys{qiW;H}jVW{~+tXx@NaqQ&49)b6gWr>0izEtalaBoU z)dnil5=Z-=2<(hz_pIZl8qF?}luSdjIN>{z-zlc{6~#>|oJ={HfyR+Ogp2%8vk(Oo zD09fpKREeVVnyAhXzZH`^T^;TNOiK+3-Dc={~7@sDZ8^mEubFmN9ZVFnXQ6On@P&TDm479crSLR9-VJW%EOfHwB;K? z#SWT!U4S{<;_2PZwM2b67$u^bD2&Ja@@=plfBBE7x}2}Glv5NL-@TA##Y@9on7VGy z&i=1b(@@mqaF&snPH4+8n$GLk1DI8i14s9kCYpPWKd*|JxEvo5Ivgm)ot~w2 zE-?fhKJ+^J>+G*|UK_KR0c5>jI=t{hFsQt1n_MUFu+V2D+jo05)<%@3-!Xs9V0+l6 z)Rod#GRR#*to`3@*`x! z%oc$C){c?Mzkk>Gls!NBWkvGAK#`4vzv>9kLu1xG(K&0r%r8T;ZW|A#uv$ng3;MEY zmf%cp%zwq!YuyVfi|gDb*`co}80jgV)a)|96>B^jpxxnOQl*ATC)t3)9NzY-VoR-DewkUs95 z;0?<(iLt zMk)SS`u7UC06=sxkj$bsF|NQVZ>=AvAe4OhxN&bh-s_NNaQA7G`x#@l>?-(TMt%@;WbAiRO}CzGFEs2~7Lbw& zI!sI`4KTi?zeEG3A{<*ZX1imaZTXzb1NR0PUlQ#g1?=jtT3L@6)@@eKb3*U-eEUrO$O?=k# z*INe~<-DYB9-aD9{-65jj(!ES-c{1h>0_B*J$N|paUSA)4siZyaH}5lK5d4@K_xIL zmg>?4vwVM&NbL8)7O&}CvFXCeXJ~z3*39h9kQ7=LF66(odJ=Bf5SZnCHyJTnb~NF_ zW=LZy_ZCZMlH{jRu6fIJ(ok@k{2O=Z&&fuozD)3cx^=bQyLw=LKo5fY;{W+C0ivi0 JE|s?m`5&0AAxr=O literal 0 HcmV?d00001 diff --git a/frontend/public/favicon-16x16.png b/frontend/public/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..ce25bafc2ec74c0e47987a5575c9a91e00c70c66 GIT binary patch literal 439 zcmV;o0Z9IdP)KCZru~DUP1ENX>&CAwAZ*p}IoiJdy#P^~ts1_C z+cbW=VcYWkg-!GK=T?p1o`En(%%<^MC_&9ujo+TRbbWghH0{UNz-d3e*tdOq2{Qz0 zP$+I^H-5VTQsg)F`{$tPKfZ#nN8k7N$Oc$9e!YR!J2p+9V_=FvHp2|?p7i5G+Pv?t zBPM@;nK zU>eZ)wFM-ZJm<&jsx`mfwQc$N)uZp*JL_hI^AY%K6P5t{9F3_EJGN=~8Hr(lef@g@ zSRmrV){S4UyL7zc#~u@n-$Id`2}%PmAQ;Ss7*PK;hyfok%)HxVUiJ2yMfLmt$k@E{ z%{POrJN0-qgMh5Vx_o)(O+PfkPyE+Qx(vo3Hb_j)VQn5k13*CCY6Gu~;|4ofhxOqg h3}S=Ch|)|d008t?HoT4i56l1n002ovPDHLkV1mi8&nN%@ literal 0 HcmV?d00001 diff --git a/frontend/public/favicon-32x32.png b/frontend/public/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..53129f294f2a912a137e0498899f59fa88d43719 GIT binary patch literal 877 zcmV-z1CsoSP)28V9gAn0j^k7Uph?szQp+BgV+U?Bl(vme(ZCpxA ze{$nXzGO4|=6&D3c{`7y_OXu=&^xVHn_YU1MUWc_%`UlytWkRn2FMN;Vh;z3PwZ3Qr9Ei^t6Sa>i(M<6aCsky1v^cR?e7A$`NxCMKcvb^b zDry~7e&%mZxuswOER0WNCg{#m{xnbDQ6t7!yBi0-`IDQ*H3s5)1&Q}g>($x*^L!PU z`IJC|(^{l$6bJ9W)ndd%Mnf)Af?k!^fZ3(jWGf+QHlyf(S5vNwno24&@J#!4)2D4* zd8me${976^ku3)!$cOS8;9&fCw#|;)YN+pthKcxv2O4_$Igxn$A)&onPHs-jYQGD) zpvbkI*MLQk8#6arhZX6snOPaH(|C4xMBxOZbG(9asYoRs0j(kRI9WB7UD6gP%wgQ zue1TPAls;7zybWCf(E=)v9P$*2ICkm{p``Q8ODkqMg(M(8prW}q9Ef$>#P%n2~*Et z=U66t85r-N$^ir89V|dAj37swZ$#DRp^dHPp$$C;w@3_$WdZuZ38;rE7qs2iP}e>e zYPzDzd_7;Bt)cc$j3;?`l00000NkvXXu0mjf Dpy7-A literal 0 HcmV?d00001 diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..53129f294f2a912a137e0498899f59fa88d43719 GIT binary patch literal 877 zcmV-z1CsoSP)28V9gAn0j^k7Uph?szQp+BgV+U?Bl(vme(ZCpxA ze{$nXzGO4|=6&D3c{`7y_OXu=&^xVHn_YU1MUWc_%`UlytWkRn2FMN;Vh;z3PwZ3Qr9Ei^t6Sa>i(M<6aCsky1v^cR?e7A$`NxCMKcvb^b zDry~7e&%mZxuswOER0WNCg{#m{xnbDQ6t7!yBi0-`IDQ*H3s5)1&Q}g>($x*^L!PU z`IJC|(^{l$6bJ9W)ndd%Mnf)Af?k!^fZ3(jWGf+QHlyf(S5vNwno24&@J#!4)2D4* zd8me${976^ku3)!$cOS8;9&fCw#|;)YN+pthKcxv2O4_$Igxn$A)&onPHs-jYQGD) zpvbkI*MLQk8#6arhZX6snOPaH(|C4xMBxOZbG(9asYoRs0j(kRI9WB7UD6gP%wgQ zue1TPAls;7zybWCf(E=)v9P$*2ICkm{p``Q8ODkqMg(M(8prW}q9Ef$>#P%n2~*Et z=U66t85r-N$^ir89V|dAj37swZ$#DRp^dHPp$$C;w@3_$WdZuZ38;rE7qs2iP}e>e zYPzDzd_7;Bt)cc$j3;?`l00000NkvXXu0mjf Dpy7-A literal 0 HcmV?d00001 diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..710f43b --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..1e605c6fea0b6a36cfbedf0c81c3188bc9b6c564 GIT binary patch literal 5159 zcmbuD_dgrn_s6Xw_9!(=5sJ1}joMnRy;p0CS)-yfwpguFRjo~9v}Wv4^oEz6M4yD&gDyL;}yI^Wo+dSJE6PPv>Y zUrN=A1AM=wS8Gi4ne9OrkNR3Is;bMKfE4r zxA8onFq?VI!uyH@Juj1f=Y|! z{}cDzdfHP2;_gKR8o z(OY$M>oZF|juw3h60A6B);)t%Tb$HiM63SDV@+OMsf2Gp|2 zbc)LPHC1iT8c0CRt@|>Icp_N{EC`kI?b$a%Ll&#Kb!o4`GQ;cGmcnC3*?LRsJ|gWK z4MU?_OKI7y8s*+;tG4x%@4)0YY7{!}RSxmwJRfoIZ*k3wAVZg$h?L%?hYtb3%yV11 z>D)im&UIc|F5Hnj^z@ZuerDJ%EEh02_kF&8ZcAT@F9&E6Bbw5SHx63o?678O16n~} z+H0%utH1yWte~xW*-2}cye7Atm@N~FCxQ_(c-6^*9tDy7V=>96Ya%!$7NF?;&Wk~o zbu&GVg7rL>?+T`UFT74)Z?oyR47{$(dwcfH)Uwmi2d6@<(0PsE7vHqVM+WZATlcM0$@eNjpk5$;r`%ZvkN+0D_eqAV)im^ z1%V<6*=ng}RVhHMJ2S#5v4@s_A3ATY+U{(@P|32tUk&}Tg(GV3qyxhSn%tkf?3aB+ z+vtE3^ zv&h*aUx5&|@>rL}YKLxEo$0co>)E*e#ib7=4EgIT?^FK&oI7Mu)jg+iWvMS@IwfTg zqcw%b_9C`8=}Rqd>5!9-FV`?#<^jh7`n~5&%~Go549Fn~XlWI?K=`j4MI?~nkt-2- zGWTHAsl#;HM*ryV6czhkfKK4;!6ste#cGUL`ze)41$%grpW`0}vdCC>W#+L-hY6X? zU)x!(#NG_uz)6zZtgS;%qS|EU7A4QJRPzea;jk+D5GKt`kCEETn+6b<=B_vCorLk0 zo`v79aBM2#eR@|vrXk1UBPac&YR{-R)39pfekdaz+mic`@lAN{Lqlk;b=HISMdqvP zn27N^w>Ns`9ps#6BVjFCc-b{h`#9O`)38RPq^9Sh>wOnq^#VBOZ=*SEM$)bRU|q9| z5MP0eed?xfplTR-><>;1F4QN6CTMnwqR5u@Yw5UP^dc{gfdMtptw1Q+$US=q_llSQ zITs}%nXbFG{}4Xs;b}Trp~xrmXR<)_o`P-tdr1zxhGC5_Ta%QU=4V27aXs4W$?pH% zWnWAx;b!~~n^`f*b{js1Jhl4iYvc7d#czD5SE02`87c90pY92p7>GD>m=1a-d|#># z?O-ZbzNCg0tY{NIR%-6#dw+eRe-MuE+0!%(@6v>wN~iY8&6Q>(RS8ZSBTsW&#rs69 z*S`>M`nukzqK<0=MWqrl+)7F07|#?crZCCJWN^;64PKzfpM1I(!VUN4+`d~gLS}fU?=)|HbVFj=_(fW#k}KA;NV&{0xR1e1+$MRy zSZ?HHG-aVd2Hql*PRAJf+axWEZr1Q!D$~sSIYi*7S+QZe13X?Ti?#4(ck>-4X&={# zzFw7}_)7V`f+}(M9+O}^`^r-8M5=zKY-CShU;cES>=0I40rUC3ijCi}$f|reLh9M( zkuuxlO$qntg#VCsTXNAw8M2YqSG#D(E}!NI0d{75yWwUe)+viX1u3h#l(4Kb?M&aA z{53?bjBo@FHi?%o+;fjcRvPW(k>|2|lsPQlNb*jLaJ&ZBjAzg^ zDvS!6c-f&>jH+4UUdbWpV`iCM=0OFXHW3wT7v`R1+f;x3FEibOw`$AK_sRjdeJZ=K z*)++1=QXI%xa8{S!tpG^^_4d=NN!5E57DX%?F|RHWT>Tnm~)hYKp0$rCF!?tJzJZ1 zRH#t`)30ZoXXAt*9Rx$&SMj_YqCLFYBL-2Hj9R9Jzs*KQI% z_F1-t(wcKO?o^v7pX8%hl9KvMq{=X>-$>`TzlsHU<~UKv?AjODq*?|@dVsP0iyG!9asL*({r2^v;9AyJ0457a$*JQidfM9;_UvBL{$fx} zZWqtB;pfChiOfJK0+mWXMfHg?d0F@w#|J`tEGz-@{gqvMcN6B8TvdoLY%q&lFiw|A z`jTAc5s?_eAug$y6U0dP{Paa3fjSg@m7#1fF z{jef_54E`kBj1CHFQ&X8#DRJ=PD5h=N6Jyru(^`3n`CeH{ZvEz1nq=5U3;+vv1IIm z;lYn^?m53NtSZzUCED|s{l6e9^)13|ZKIdyfTE3k82No9j<8Ahh;OfBeW!N`#AGA! zDr|l8!#2_5&P->oad?Hmd(VD7^%mjjk^vX7pbw?ov*PbCLX^rcE7O=Itu}B zn=PX^%A;P7?GBn7=Pl|j z>rD*f%Tu7!h*kM+Q~jv%(2<}WL+R`SQV*6Pp{(hka5&NRgw35SSU%=v=-rH9cvnrP zufnpldp(+2PF?KIv9XGidB+kJe7dIB`1eh;tqTq_ITKvEQPhu<3YlB^;D>t3Kii_d zi!_8hE~x{#(V7 zGRIjOsFC9o;?H$GP{z=tqVwxkCF>2mVmq}(QeYXUNvqDXV&Lm;dhW)ATxU(cOKO;n zMPpfgODtUB=P$@cIqid@sf2<9mG-6O@IqI*cjjUJIGY#4$2kksn$+Bb3S!b@G#Fpw zsSqow$eRzSr%!u7lOo`L!3GLxQ)nC(2J5fE`WBdX7LI|w1YCi^HAs3~1qwZcFe=nh zbTdzoI$`u%6Pj3qH$tb^;IY634epxjlSTe^8Bi~F{m8j3oM}lEG#0t-&AD|qxYt5R zk-chpCMPB(0NP*BY-sY3dbp)ION7b%Bz9(>*iMF{^Ywg)K%A`5*r}mH737ZtgLAy9 z9KCQImw&fT-`Nx^cdbmi1Q^A2uZo?kpGAG;mo*5*JhGEd*2PryUPe2IC69eg*yu=r;YDI3auU(` z%6!6Eg6szMl$vbiba2~_vC6Jkv~Pr^$9Zb}o)4!tn`c zUKXdBN|o{jlsL9SHN8P~teMIqV_89W4FetJVg#-E;ha;opI_Ia;=NRdWl#;*;GtLFijK~rxY3p zO`7H5cgonu=I_K2lGkOPxDYGv{6pRI=#Rxh61^eUF*fd$pSkW;f+Qj1x?gU`K7E62 z5lGyvYO)*oZILHHS%W;DO$ zr`xD=2@*YDeC#f-%|90*{hm{$RV2Vm1`)VmA0^uCWDhu@`CJ}t#mCtVB(w}^5Kd0&P=^rRALMy-Zgs;j~jlnll zRM+%8Kx*hCExwx>T+2`A3y;*C2c1O#rF*oadR`fgD#yb=cejSy<{L7CzvU=M`b5s+ znD`Nx|EOCz2Sp^L4xjZ#JSx8zaJ`vXm61vl6w%=Te30H_y~QBfS*d105yxz{%e`a! zON7mu5oCkz#`AK#!cNQ`iV3o#{52YsA}J7xN@71b+uP!@5`NN;T?dieErT}zFtT#2{?HlD0ve25S&?OjF$Tn~j2jO21Vwzs$rw%G7Hvjzw zJC1Kban=5jbL(#t8=toA|7*wCqMPg{i3Nm zd=lEY(^%iQq`|6^(kpoI;e!cdyepByD&VLX8ilE9ZM?Q0u=hm%%bHqvOOC%RoG}1on%||gbftrep*y)rkNOU zWjP<$kQ0tsMG;>45r-Z}$##gK=zxS$xfe^W`G2JHs>3Co%;PJl!S`aoRR}JvOMM?S z`)D|lPt7OqS-^mLklc+bOdi_8GtwSvmB{+HJLODa={X3#d@t4+|LObMW z*yuU&NHM$2@%yyu(M$(kbiYK-|Ki8_fRmrR&P7n%t7=Gbj8o#P@!P3L!0mc^%GIY? z{M1T?62Fx?LHXw+!BrpA<^1nn1YX0BPT2UjbBWSMk=_+#!&zOCZJJ z2T`wXF;)!yb#&S5gumauW05Z$`K#}_!s3!|MdAcf(<5ByAmw%Lx;iz4)1(L^TCuY$q!s@a|5dev zE+!)3LLWEcVs#i;zplp+%j&5X+@!%r4EANuC<8f=1Dm(A$GvTj9rvhu#)dD!=XWD4 zG7`@9Bi8E<>jfbTZ-v%vKt-BLh7-c-|YQ#Ht`?n8?h0`>>62 z6)*O?ADPKUQY!5AV6F{=a6#;8sVJ-t>!W~8{#tgS@B8jmwLT8wHHK7kE)!lA>Q9Np=ONQQO;n8&#|{!)I{~*3ftTPr z)aayWHq5TgnmC4msi3+SLeo>`*>Sm>fsq>D5|Q?bxR5Tv6>4Mjj|fPg4P1eD&J(v{vJL_|co z(xL$pDFFdPC=v*y+!NpT`^NnVcU;C`z&P1wpMCaTYpyb9;_n#W;ylKG3;+O5z1zBX z0e~6&ml-(92LAaIJb3{AVSjwv${zqY_!z%TKu+Fi01yTAbg!BR=Mm=EvjhY3H+?^_ z*zpDg1l)7^c(%HN!%Z~1W=$_Bg`IsVvVu40-iN+>LE)CBx_inxCR2u7%ssDkMD4o{ zvCEuu-$XeQ@;V}I=QD2H_t)7lOxifG97_)^bTDC#I-i1*^i7VZs zBLalzR~C+f>-zuwN8#bLa}U%8O5q!?QN;wlNd28h4m0T73z(6jHWGq4Wcn$Se#5dE zFz?!>U{tg1PSGPMR)j<+kq4Y-vEMwr2XQ#_`?AJ-)06MDr`Bf!vmrXGW{1b}O2{rxvpW@Pg-0y|NTodr(qrT7-70*k^g z>uH3nayD9GMCTMXyj$?}mpz@s6#&qgd*lbj+Ns+SgEP&wMuwyU``@7p(yo6Eo!Q;U zBEA7iYX|R))a#3xaTA{i; zf|XRIBf!tyr$2A2=X*87>5fqBNV?e^hR@&M7-}CJ`AS^)S*;JDQznua?=zREc zK@3L8FFQsFyc~`DBvHNAUf|TZT+d@@ZJunqHHcbl^KXRcS`U=`4j1}^=cFG6L`<^KL%Et@k1;C&qK!i>FM*H{hcQ8g`(hl z?N62t9K5VfciF+A5a4%!h4)I~jL>x0Uf4HN}Ha&YOX)6(beCZ_^) z!D&gWA2oZo+8;dDms7KVYXgy=vYxnaCJhQvllCBl_{fxeLa_Xos1I4QDqO5Uk^Ry^ zwQa1kUBodP(4f+z?{4q{k^KBfnP3h+?Hgxjl&cBGbmF7jDXb45I{r>w^L@tpT(xE$ zA7|}2YG^SUbI1+o8XJt#;|Tlo>j+T^{;S^Zr(}S|F7vw}LcaChhk|bPoc2uy;obcy%s&6PR);&bDKH zg2#YJ-D-_#w0(}3x(o84$Z+z5XMdMW=8;E690V#4z(45sV$ln&@lKP6&)ubSdC$qe z?;VMD6bL}8pw{iS2X4WCUO0BI#_F@tQ{dsyXh`9_xOT~Pl-rjznZjyhA+yfNmn*9o zuo|?mW4zdF&@Hns5vV7Do979nmo1>*9~9}IDE35aJPZnz#T?25=DW5iLXJUuZ(+rO z#F0m)GSi#@&zZo{%Qi`^`f%XFiyJlV@0)wIufkDmkyoty%TRd(T8ss zj!L3&0+1{QDh$fXP6|uLFW3$$Cn%{xd?v6srlOA>gNLv3$|i*czaOkjyj z=WQ{Hfq1er;p%J2t3Q%LTKW{v5U3(L$B*>9-gZ&i*p|2pLu$DD>&mI=!_|OSJPZIL zWEDPh1kQ`~A7)Cm|GZD|WzrcQXudVGwJ{;`C(aGEaVB*qX+l^8_+&mP8$K8~oO<^3 z@3>zd3hMkyCz|`&0mak}NuzpOVkyr*e!5TM9&sk6Et*UV%LAXD;SU_ro%Na_tqQK4 zw3uOAg#QdPVtwg(+4^r0GPK;m0js}BVBrJx?gLb;*j#95FX}#g7T|fnaD@h;S|wH%>t79ajSnbcjW@)-vp>1Y?MkP@ zWmB|a2gebalpjArToNdD7`pun`)y&SAF<8-ya4Y+gF%_klM$S7TJ^8GHlyub$F=D`$f66e1#B}OxkA_eS4{Wj@W2RR9%Zs_sedll?{GEnIlR> z^d~a*JJooKdY41@lJd$r>P@;Cy*Q`6$=0J2gDazkmpvAT&?74UAyqE@-DB>GUo5C($Ye*^|NpEfkr zk;bB~Wv$Pp4-kS|N&Bot#*dU0hLl??xmAue+c~#P`5|c)DmyoExosbCRn(m#p5+`H zrS_kv3BJd4gr-TgGVE2FUnI{b_uX(dEsSYu`fUX6R1MX?_)Z`PkqcNj4+6IQ5%!*+ z-L&-cs3>0kH=B%Xc0?;5Xufd%dBusmViY! zL--QK?aZ)cn(a4B^*GuDbs#Q5`=?;Z4nWl-XrEq&(=?3xd}56k6Pgk`HQu1Qb956cP%BBj!Pth<6P=nMc^E73;3VrUOcGgdn@yte>U1Z%Kw~i_Odb!fqWPEJ zaRwv*rO$5M{s7mF{-xfD>4;Z*QVSdc&8wmPE~w80Et!t4cnN!Jyt z!xclu%Kbrbg(bsv7L3~E)G&S{1WfePn`S4;&?bq2HdVYeB9=6k&9H(2C=1}6VRP{M zz_IA-KG}sMfqJ>9)Oi|G6rJHW@{q8_eOsm)xqpLjRp*?#tZaGtltllt^U0QeQjuKN zo$3y7LDu80o^oo~K%{mRH*k!B1MVg-^w!@!kH{pA<$uh4r;;;Dmhec9)p^JWVP5?d zYxoMfqIm-M$&Q|Al40phc$BVl#f^R?)N1o2!I$?o>yQ*xffb6>H#>FM*(A~XRwckU zq?|NXcr6DeHz9l~>bn6H`~AEYQQjzv=<}_BH5I#0)>361zlw&q5fm~=`$s=br`bM! zN63lgQ~%KB();HE9?ZKO$8#f)D4U3rm>HJm3TO=sBidmW&+{0x*mRhNOITKUMWOD& zx8FpG+d8*+_;8;X2XioZ9_zz?Fb109+S40^8n0sSmTPW((^1OUzX4^PIr6+|bh{n1 zc!5V?H3W)tf-Xz#3fWY~?{F5fBVn*>Lv?AZ!|{sYx)$8ovhl%vPBW|7CQi8P=ghvxfP#V{<0WKD_*%_rg6H3Sat?Ybg?W zrwtZhaLPToTIZKDm`!^@m#qjw7Et>GzWiW49wE6I#j$}S&(`X-hjt^%kxntidg#Xo+EoK%#_+@9P1nv+$fMy2 z;h?BHb++_s4Al94PRrZjIXbV`LCPfqmWfyTL22D3nj6f;_lY<#3&21h1)rpEO-Wp; z>V5)AVE#7WXxG@`{9rSXuyyK=bI?9+5__gMrOVD3&iv-JvM&=jh66m z+1ZQxutVQ+Ury|H?d(1Xyzy-yaM`ol zN!*k@rG&Kq&!mZ*+F&5^a4M+j)l|!e6}Asq${^aazMWsDT714uc)(wgz`%T(@yYHo z+jS6j?wiku%m3+YYL$Lr-(r^cR@0az;6ZC{ItF_GbZ*D@l_NTe1`%bLnSXD{ch0^0 zDqJ3w)5u8Lf@2qRpUVK=7GnKJ+zZ|?VUTmp;48ClIDL*w-It)e{Dx%@P-@OjcyJgFJj3PR!4a zTc5e!-0xd-(V}yYdkt#(C3jRzr{0S{fr+y-AtSx@gz&2D`yUuItRq@8g>r>{+h_Ho zGk$5#eD%O#O1M9&>U&a9;Q`l)?J{|(9sZwLDv4-j?)EMJ;m`My_kyYLr`Okr+)vY! zcY}WzH<})xjmkGY@TJ-96-Ch!2aYXKuFn8vFzeTIT>_d8^6$`&~NGyQPS z4M9yD3>@(|W78ZPGK8g0HZej(F?h93y=rikc>5GWDt2vzgBfI2a!eNzmUK(^x{* z%fPa{xgY*BVBDLmLCrrGx^?B}{^#|`SZ%WQp&ue>{u?%Bb{@>OZg0cd=jG$k6mE+_ zS)&PIVia1h-KCzC#M?J&We%1arl#RnTwkNi;D;~9m*~F=4+ghgbO^P|ze{JwuMxI{ zgN=9u2Nn@i%Fe|StJiqkQl7bB0RKdA|>A{p~ei93L<&+sTr%~mVS z#{r{AMwk0~lTQ$QMJjHq)3iN^Jf`mn7aNV>B_&sc0GZ8J(5Z63QT3#894z@z=Q%!@ zZWJCH=eD?#EsNp%cdC4AXC%(Yy3@@nBw?1^?E-VCUF9O(-d?AiLzZ%6reBAf38o^# z8-;e9z=wPfqSD<7LSj{k>z8wz7>@g12wUdS%8`BAQWLw#NvaYIkulB~Pam*=L$TW8 z^q4=-NME@HUxP_kjH7FuXUmWP)8c`cXy>5UHSy09)1&~%^`=lGpP>6(%vlrxOR&pU zs!2OH|Bke=(;W1y0!(ZzSYFyFd2d$RAK9FqLiR>n9Ubidllj?s+sAV?Z%>`DCBiN- z_SS=V)YNC~Vedm7McA5HV>Jdwi_RyB{Pz2BM0ItJ@52&q|92q$B4e~`CzgtVUYpGI z1GiZd1kv2Cx(;RFzYaDXqFH0O#*La({bfU4LMf5aDl^JKuw?q}x<6g{#CoBf7o;Sf zUChaSjbo=9tHN0y|JU6f4b0y3=?vRO8+^78fh3SLBAvi-0CjXL|KzClB63vEDvOVqep>;O!!| z+oPx%8#VF%kitCfX6E9saFbf1$dg27?|qnKO;9cPQmlisf?Xqi!=bxEqT~H8DD>_??d0X>t0^mG_L!X%i_d@>xCT?YpY9{qsYgJxpxhwz6*C zF|I}rbq}#x(mP!H&DLYPndb$3F%IQ_J${AdykQ6O(5Fe2(x&XhF3o&X(`u^zTPjNC zNLb$sKXm@spB(F1-TR#42;_KDcB!U_=mVL4|7LLb1$0uggaW^ntNcY`#je>1M+Yq~hR#OPe_O3e-sy7TZ+r`_N-iM;5UD=i*ufb7z2Pq$9Iig!(V;r~R{`xs%Fr&cLd~F&qFec}b z@as2J>FbOo)mLX=1<>iN_a;9ggfOu`2-tUZ_u2IInzij+jO2aaJmCp~j|e`K=i9o0 zdS5(dhxz!&jZOJ!?9WBPA!d;dq70FQpg!>-cGS=!P_8DT1GPTk9j$vQ__^+Bw6d3P zNKlr^p-2tow*7SqJRi+70c-9kO=wyh0@uE@5d z7pLZ5NqZ|$HbBp|JUEQGBBe>yM>w40W?phl-_Y1k2rN+`CogHTHgH9e&_?Mjks^#u zIy$twF$qq9o$QM#XCejm`y+e1wZdnVkjTV?14a|$%m^)Gi~RK9%+to+`y5qGYn2CuM_v`ao}0h57K18&(qn{| z1;coy$B}E}deg4o3PgDP-wV;$F#@4?bNt?;u7h`ahg&MQmT}&1MWRqxheFEBh0TG& z5{JLws4coY?mgy4;M9~F@W<<+&fF2+skU%H@W`}xmLDc)OXqfkg&PbNyql>buJkRL zb|?pb4d8p{%O+g)PbYp+C%zODI%Mq*J)m( zb`>6>Gi*gz4g(*f=e&}x3N!g=ie$Y5I``HNq~^I2+3d9bO4peC7uZTQQ1~EXXJX?)jn3)cR*lWImIe8lmZY2H80{B&gi;im z@%Dl6RaBMFFLv0+Fdx|6Q@}wlTYHKFBKFGZ(}TyJML*asX9N3KWF|$|cI?#I?_c@S z2|1if8EFX^KEHFR=@enR3Du=3`kHUH<0?{fZY=31@N9xM`DHvw+u`qfB#|pgF$JH* z{JPddH@QA8kYN=Orb=a8k0sru*qat`N2O)}kv z8J~8z7BFQ*Yd9#hEic?%MAJ0X^X1ET@qcKyg{1_!+9e_f3i<88?qf4!lkqt-O{#z1 zszgMD7>rBMS5*G&T6%w;>Bg-`f^-voZ2*h)5ph|?-4y!ggfRYy`rGk~|9v%|YjwpE zEtLriQqqW~l$n9l;~37Ef*lTCrwVI#m(}KFFIM}Aq}OfyIu;dm=9W@jn;}3$+ek_$yaTo0*zjOmyUsuJ zpwu^?CQc|+G*#X@ExMNUJn~7Zvr9BTlGt4UVF7{4IRwA8!&$y9)lkpFvzAmExqD^( zBEsn#_szQ7UQS$WB`V~hr}})g+pQk-u=tzD4Vd0+1fO|jj61~P3AsVL+*c}-(%y+l z%71!&YViw)A6xEaMp7W7@h6ruy7sL|nE>sr9-O!@@glhEpQF2d>yZNVN_vY#D6+?b z0$b{dG@K36n3=I5pnI20%nO5s6xK%lM!XnE=kckWHg79_Kh(!$uQ8VVL?nBHW=K1M zviOTAU_E@aCr0?tgo>(}FQ=7H@9qwx-js*YIl_@;P`AzYXFSFi4(~Ol668EGu9(jx8fy-}aWCbmvWHP8|B>pm+;rzM>HkaT_Eb zn-83b6TKkYN1{Y#H46De9mEuD!l-_cqL5>~cExhg+AeaJ0A;*;pbm`pevL(>Pbi`~ zA|$baG?r5#Txy3X_5K^4A*FHT2GwDeeFfx7#g%$N`kj5IT(i+{BO0; z`p+I0qW!Wcgd=euCGdEC0(lSheACiIWkX!2{hP_C=NFV7JXh4i zt$lozu^?}-acdubo!fMAB`dL7n7G9TzmXgkXy4e3Qk7x68zU)jevNWk9%qr5HpmD% zoa&0X9WmC5_(o8DxSd&xY<{B%G17f7G@9+g*h*FLR>FM{H4Oh-#Pe68n`!eZ-Zej- zFt{F>tVTMk^g1PWi}wSgb(yVBb0H7~Uc3Bla#12#>x76Qghb7M<~lmi)qS68$(4w& zOwLI^WK1}IL(hr&kl_dTVtFIVN8Em(zmpYwvPfz+zih!FDt{#9-)#`q4H*HWqtTKIHeA{UwLw0s3s|T;BkPktOU_ z8_HFebHm|3X;D_;bH`{)Bkz5Iflm%YD+dDUvYDF8hc=`3OT_(hx{XS8!5SeQN^uAm z+9c5;+UIMh(G=(1kAG<=+{j3{F2n`8-2Zn;fs{Z3r+sI$&0%%7bVRGjAJsK3`V}rU zfrg!tjK&&8yNQ#TK_gu~wR6u9PVMKD#Rm)X^H+T8uWu4B9P@8+l0z4qdc_kMi>N2D z)#krzcOWoDT5jOpPXj%Mye6@m%BlG9YAYd!h_Otch5SFd=DD8ezv=h`nbsy-crV%;@1)sl zfAqGn$Rw>JUecIMH(-6+J$`wxns z3%Fk?nALD3wFoxa!xtsQ^u4hiRzuOP9%GQ(qM!r>jbn<7OUWkWbT+RrolnE(04hS z84n!k_W&zT1u%b}!t%z&9nV~!`&)?1pgXO@Z-JxkGn(X#(i{}pbdi#DbkmxC=k1+g z6x6B=LK1n7hxfvJ27?J+Qv2vP%!h%p@S5IT`1iw6&LKtZ3G7*L+EkeLxbR-ovFf=) z$ga@SILWg=glK)3MXfuEqq4MS64zDTw6weJb0QPqD5k5A-x6}BFwi5srn8N9J6SK= zF}+%w7pp;%0;?Dow@?_ANDAieN}96LvPATYioFPuD!+UW-UZ3W*>wE@4bdk4^?yrvokt+`z!HEzK)d?@Z4_G(<*Lz`H-31(5_@tqc%D@NgvvtlpY zTWl!L7=vu()LuA8#mB8RQRI{9XNNt}g>R5=czFBd$EJjFQCEND+b7VHuIZj=yaFHn z3SQ~KKYat%5w_v3;4}tcM{j~2_pE6>pn|BNtGl%dvK~UGQ+$(xT#g2w7Av{WP@9Yk zqod}#1b;msTu8_Iv16pK3dz*H%Q#HFiyV(K>+{UO0uutGl+JGys2sr4pYLQL$1 zJr-;;L+Q3~K}eNL`?5GyC_YZ|Vf%6@VN2jNFU;7a`XC9syWSf5vQ#sIMfpBo(Vs}t zD0nH|6Ah1arSo!FFt!VNg4fhspV4zlp3yOS3o^YnHLVu#axt-E2y92a{|k|?!mUv` z^|QxSS&s7xm8IEOS83X_C_8tSPBaC{h?Ug!Cm?tXRs&Pf*!H-%kJgL~y5GG>?j09h zh9LAZPV!x8-hwW{m%jpcE(+}Fl8kTao@ntzchci*AE%KNI46xTvJralTj#`%*|@hJ za$pvf?pQLk2hD;z&q$--=nSwlQ^$AwbR%q?spw3D$xUJB!AITw+c1%1;Z(eEN)QIc z?qHxoysrb@`!#U?A>l54RS1kF7m$)}>AnR8H<@n9{W{kZ2fU!uAeo7QitxU+0I{U` zuaH2Pg)`F+=>sE1>grl=jfC!->-bWYHr1fi6;3e&&DM8BQriJS5q z*oW#^pXMNav64^;;`P^xCh*xK*Lp$qTT_ssSjmAUs0+X{7dxrb=;Jtb&}h@?@|Z;# z9(|{|?SYK-_ft5%_&ATemtI0NJ;rtGmc@W7Vuh}5Gf|(g_3u&T5UCh^mg}{;#^bzG zp5H_m{L22ZXZ9Vv^cN&916}j#aqley%;H5J_H5ft7m%uYgPb_do7j{F+sJCx2u>xn zft+2sbiOwz^&yRdH-uw5{x&Z`E-wje6C6MlcumurGhJZGZ*aJK^1ww zJ@y~DzWv#ZK?;H#^joj~u99vgK3>lH!7SzS@_TR!tJs*|NB{?*0h_$ZCO)xYJmQC&x+v7b6aL;tLB>sYn-I}-&}GZavt*z-(!R4&m%eN z=&}zgw+?=Pqe)|mceTTZe*skhYthWSeOgu-VNYCFqcMxZkyI@I{g9KYtSqd&eJ#6{ zTuR39E|yKUz*zbS&p$l6CuX{Q+>$}EU;JoN6UZPVUWu0M-*Vr;*t6?JK<_{TuC8ln@)aMU(6zZ;A0FUzw-Q-_#%r;K4tJJPB zY%rkb8)os}NdDXm4%#qSt2aZ=h#hmTek8=pP=w^a1f5g`ERe4$eCIK}?}zNX)GkDv z7A^!Pe=y`Kuy!nREt*THBhbgJYHsEsUVr8tX`EzUGnwAe(54iC->{Rl+`#5{JPc9{ z=#!HNe<%FNawz-@JAB4a5UC6ETPWP8g3fuw=T{<~x{E^4wL7)NV=Y) z`@fhMNEGi3+(DA}9){u%tx)6+6Ib`x3w@YVOd02^SN%DGBRGwm6Lz@$^^ckx$4NLW-CzFCfebs-(SjL2_RW$=b ztAtX5tmVld%Z-9e*vLUDpZk+`jk&s^%tPgiuE!Q=O;14~o(9uf%~9ZJB|}O#v}Muf z{;_s8KznnwTjB&C_w)AdL45S!o0p1LL?4fMX{>NN-j-&wHeNlo7JZUW>p4F1=ioxp zQJleJ4rvP0>FG`uFw}shi}eSe11j*Q1psfJQA6uHztMXI$O;)=-1&FTDY<$C7j9L6}2Zg8L}NTvi29A z)tE%fMzg$f*0#laK!eptp1TBh^*PGsRg$A>(Lxx^VQEI`I(9D0g|xy13$0rH~N1~Oj6Tb#_<@PhWp_+2Q?H8hI<9^gyi*SF(nkFw| zLnkI$@wNT-wR0i=`URxYLD?X^>~{jDmno8!eV*-QvjVc?>REH~Bz zihauBvr(P5=DHxkZwy+Y`u9|phU7&1lU+C92uLK$eXzedvrCg)FKe~!p0E_T9WRzQ zDNb*tI?V`?>EZCsceI1k%KRKDn0 zn=;ngEBN`+z`;}`IQ}wp2QKh5b*!^>jp{}miGt-w)To)lZ2=yCzrlK`T>9ASf9}`L z32asm+Kp;R>F61>kj>~Dt3UVIQT$sG=2B+CThZALNE3J%z+?92k}Arm20H!d1Ew~$ zj4FPbn!mdieaZCP!JnXMh=fsRW7*+XY{XDv6tGqzW!Bcs6+Ml$U=ul{M9-eTUGMdq zyM?WsLO7SMd}*yk#Q9i46GIt0ad{&{Z{!E1;6MoI3QR4sJ?WN>vfxed>?ClQ;W@A=ASdGlz=^3NSN#jCnasF|5h`NrDcJUAS| zHUf0T&_9>#PqR8MV z*&wr>Pr&o}(TI@s(k>!MF$IHmdkyqy#=H$=coU?dhl_Q|4E-H}tZiF0LllM%-asSR2s2@842qA1*2|4`(wzJlIK}`rahL_d?&Ur_uqTwf|+Z;~g4T zwg&OLa0y(5u2Px{&9)1^@7;c&uahdUIw^Y}k6uF>u2&i!ate`6`* z`Saj^eYdYHcGj8;pVcfTSnLw&>=OrP$4eMftL>IX3OuR(Ac5L8e+;^Em5H%=;b@nq z)8;@z!AnXrwOIbhKft;3@+ZFuf922aLK@W^DI_B~*HTX=%7h@Eb-mbIIuq2taf1G! z2JQJW6J5nnm*IQD!?aQ{}zRM^<3N= z%{F(iXSn`(J83WD$IO((6@Ith+?`KN92&a)rpx?7wWCsL)y~JLZ$7^Vs|I@@E_>PV z!RrIyRLLIC7iPun%RSkdTR96iC5E(y<_;USola!nz08e%P^K`8tdXY=_TS-s5{Ei~ z=8dI$=QP; zzNY>o0Q*+)^tIHmwXedIlGzFni;Z&vUGZ01-dz<{JvCVAP0(w%Pi*%z+pP5IJ`uKE zQht%{LT()r5Nt+pnW7fyz1cQN6AZW;_H~t|@05Acgb7y_k|1mnhTTZN&pO6Y2oU9^ z(J1`LlLz*>jv(fkgFVium$QOWuF$1RakJTD|^95#0z6dtIw{c$o4wr{THC z(BF{ofp;W3y%5};AUE!py1q41xwyGJBt8Li2nD)EWBcE$Ss5F6aHr_j%cP=e-NR_- z2~#W*ctdPQ{-UZfZans46CFHW_Jmi+(Yp6b)$QD_wbzu{g+8w4~x{Ket?0k&A z)5cz{^$w}c@}UmIuT^mH18Ps2(&7M=)1V%c`yX*C&c~A7xtny0FPa7aX&aVx_<2P;@~tiZeQci8pGxj-U4GDv zK^)IFT0AzR z7_$8_ESF7%YKacw25vB-55S{uw1^>-KqF2DE zKY=ZW&2 zR0Q}?)9hTFg}RccDz^*A`V<7A?_onK$j{k5m5uLrBc1|YDfGNH%1(C$x6jy2g}(pc z&Al}~mcDw!Vu$IvS6=T!!X8{s*adXr!X~ZB#_CW}Ro=mci-J5~;|5RIr{wN0nRRPa zmLS7ffS5QIW_}C9eSpWrFM7Jdm)w}EL>}71HuY(;4tRz8t2n&!ng{pNEN7y%`xg9= z6}bGh!f<|WJ1-D?u$NUqo0QFWo~Hxx9$6Gauz6c%SZtSp6-u|FWnyN80MTbT7>kGs z&@|qa8P2C5%byWpqsy%xCXb%9ZQDp|R~ypA)r+br|IAoK+=j!M0o_@K>*IVEEt6n_ zC%eIulkpfmvEjz>(7choHIoQEDVz9E#zR>l87k}%~Mo1%R zjGysMv-Q#b#7_M1@^4wQ+)K|BE<)IUP@2KBr@M_V`S=W;C; + + + + + + + + + + + + + + + + + + + + + +`; + +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(); \ No newline at end of file diff --git a/frontend/styles/globals.css b/frontend/styles/globals.css new file mode 100644 index 0000000..1bdcdba --- /dev/null +++ b/frontend/styles/globals.css @@ -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; +} \ No newline at end of file diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..6307c14 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,43 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + './pages/**/*.{js,ts,jsx,tsx,mdx}', + './components/**/*.{js,ts,jsx,tsx,mdx}', + './app/**/*.{js,ts,jsx,tsx,mdx}', + ], + theme: { + extend: { + colors: { + primary: { + 50: '#eff6ff', + 100: '#dbeafe', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + }, + success: { + 50: '#f0fdf4', + 100: '#dcfce7', + 500: '#22c55e', + 600: '#16a34a', + }, + danger: { + 50: '#fef2f2', + 100: '#fee2e2', + 500: '#ef4444', + 600: '#dc2626', + }, + warning: { + 50: '#fefce8', + 100: '#fef3c7', + 500: '#f59e0b', + 600: '#d97706', + } + }, + fontFamily: { + sans: ['Inter', 'sans-serif'], + }, + }, + }, + plugins: [], +} \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..5d8fa8f --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "es6"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} \ No newline at end of file diff --git a/frontend/types/react-plotly.js.d.ts b/frontend/types/react-plotly.js.d.ts new file mode 100644 index 0000000..2903d85 --- /dev/null +++ b/frontend/types/react-plotly.js.d.ts @@ -0,0 +1,5 @@ +declare module 'react-plotly.js' { + import { ComponentType } from 'react'; + const Plot: ComponentType; + export default Plot; +} diff --git a/portainer/.env.portainer b/portainer/.env.portainer new file mode 100644 index 0000000..0c7f042 --- /dev/null +++ b/portainer/.env.portainer @@ -0,0 +1,46 @@ +# Stock Oracle Portainer ํ™˜๊ฒฝ ๋ณ€์ˆ˜ +# ์ด ํŒŒ์ผ์„ .env๋กœ ๋ณต์‚ฌํ•˜์—ฌ ์‚ฌ์šฉํ•˜์„ธ์š” + +# =========================================== +# ๐Ÿ” ์ค‘์š”: ์‹ค์ œ ๋ฐฐํฌ ์‹œ ๋ฐ˜๋“œ์‹œ ๋ณ€๊ฒฝํ•˜์„ธ์š”! +# =========================================== + +# SEC API ์„ค์ • (ํ•„์ˆ˜) +SEC_EMAIL=your-email@example.com + +# Alpha Vantage API Key (์„ ํƒ์‚ฌํ•ญ) +ALPHA_VANTAGE_API_KEY=your_alpha_vantage_key + +# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค์ • +POSTGRES_DB=stock_oracle +POSTGRES_USER=stock_oracle_user +POSTGRES_PASSWORD=change_this_password_in_production +DATABASE_URL=postgresql+asyncpg://stock_oracle_user:change_this_password_in_production@postgres:5432/stock_oracle + +# Redis ์„ค์ • +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD=change_this_redis_password + +# API ์„ค์ • +API_TITLE=Stock Oracle API +API_VERSION=1.0.0 +DEBUG=false + +# ์‹œ๊ฐ„๋Œ€ ์„ค์ • +TZ=Asia/Seoul + +# ํฌํŠธ ์„ค์ • (ํ•„์š” ์‹œ ๋ณ€๊ฒฝ) +POSTGRES_PORT=15433 +REDIS_PORT=16380 +API_PORT=18001 +NGINX_HTTP_PORT=80 +NGINX_HTTPS_PORT=443 + +# SSL ์„ค์ • (HTTPS ์‚ฌ์šฉ ์‹œ) +SSL_CERT_PATH=./nginx/ssl/cert.pem +SSL_KEY_PATH=./nginx/ssl/key.pem + +# Portainer ๊ด€๋ จ ์„ค์ • +COMPOSE_PROJECT_NAME=stock-oracle +COMPOSE_FILE=docker-compose.portainer.yml \ No newline at end of file diff --git a/portainer/PORTAINER_DEPLOYMENT_GUIDE.md b/portainer/PORTAINER_DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..7fdab43 --- /dev/null +++ b/portainer/PORTAINER_DEPLOYMENT_GUIDE.md @@ -0,0 +1,240 @@ +# ๐Ÿณ Portainer๋กœ Stock Oracle ๋ฐฐํฌํ•˜๊ธฐ + +## ๐Ÿ“‹ ์‚ฌ์ „ ์ค€๋น„์‚ฌํ•ญ + +### 1. ์‹œ์Šคํ…œ ์š”๊ตฌ์‚ฌํ•ญ +- Docker Engine 20.10+ +- Docker Compose 2.0+ +- ์ตœ์†Œ 4GB RAM +- ์ตœ์†Œ 10GB ๋””์Šคํฌ ๊ณต๊ฐ„ + +### 2. ํ•„์ˆ˜ ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ • +```bash +# SEC API ์ด๋ฉ”์ผ (ํ•„์ˆ˜) +export SEC_EMAIL="your-email@example.com" + +# Alpha Vantage API Key (์„ ํƒ์‚ฌํ•ญ) +export ALPHA_VANTAGE_API_KEY="your_key_here" +``` + +## ๐Ÿš€ ๋‹จ๊ณ„๋ณ„ ๋ฐฐํฌ ๊ฐ€์ด๋“œ + +### Step 1: Portainer ์„ค์น˜ +```bash +# Portainer ์„ค์น˜ ์Šคํฌ๋ฆฝํŠธ ์‹คํ–‰ +./portainer/install-portainer.sh + +# ๋˜๋Š” ์ˆ˜๋™ ์„ค์น˜ +docker volume create portainer_data +docker run -d \ + -p 8000:8000 \ + -p 9443:9443 \ + --name portainer \ + --restart=always \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v portainer_data:/data \ + portainer/portainer-ce:latest +``` + +### Step 2: Portainer ์›น UI ์ ‘์† +1. ๋ธŒ๋ผ์šฐ์ €์—์„œ `https://localhost:9443` ์ ‘์† +2. ๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ (username: admin, password: ์„ค์ •) +3. Local Docker ํ™˜๊ฒฝ ์„ ํƒ + +### Step 3: SSL ์ธ์ฆ์„œ ์ƒ์„ฑ (๊ฐœ๋ฐœ์šฉ) +```bash +./portainer/generate-ssl.sh +``` + +### Step 4: ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ํŒŒ์ผ ์„ค์ • +```bash +# ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ํŒŒ์ผ ๋ณต์‚ฌ +cp portainer/.env.portainer .env + +# ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ํŽธ์ง‘ +nano .env + +# ๋ฐ˜๋“œ์‹œ ๋ณ€๊ฒฝํ•ด์•ผ ํ•  ๊ฐ’๋“ค: +# - SEC_EMAIL: ์‹ค์ œ ์ด๋ฉ”์ผ ์ฃผ์†Œ +# - POSTGRES_PASSWORD: ๊ฐ•๋ ฅํ•œ ํŒจ์Šค์›Œ๋“œ +# - REDIS_PASSWORD: Redis ํŒจ์Šค์›Œ๋“œ +``` + +### Step 5: Docker ์ด๋ฏธ์ง€ ๋นŒ๋“œ +```bash +# API ์ด๋ฏธ์ง€ ๋นŒ๋“œ +docker build -t stock-oracle-api:latest . + +# ๋˜๋Š” Portainer์—์„œ ๋นŒ๋“œ +``` + +## ๐ŸŽฏ Portainer๋ฅผ ํ†ตํ•œ ์Šคํƒ ๋ฐฐํฌ + +### ๋ฐฉ๋ฒ• 1: Portainer Web UI ์‚ฌ์šฉ + +1. **์Šคํƒ ๋ฉ”๋‰ด๋กœ ์ด๋™** + - Portainer ๋Œ€์‹œ๋ณด๋“œ โ†’ Stacks โ†’ Add stack + +2. **์Šคํƒ ์ •๋ณด ์ž…๋ ฅ** + - Name: `stock-oracle` + - Build method: `Git Repository` ๋˜๋Š” `Upload` + +3. **Git Repository ๋ฐฉ๋ฒ• (๊ถŒ์žฅ)** + ``` + Repository URL: https://github.com/your-repo/stock-oracle + Repository reference: main + Compose path: portainer/docker-compose.portainer.yml + ``` + +4. **ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ •** + ``` + SEC_EMAIL=your-email@example.com + POSTGRES_PASSWORD=your_secure_password + REDIS_PASSWORD=your_redis_password + ``` + +5. **Deploy the stack** ํด๋ฆญ + +### ๋ฐฉ๋ฒ• 2: Docker Compose ์ง์ ‘ ์‚ฌ์šฉ +```bash +# ์Šคํƒ ๋ฐฐํฌ +docker-compose -f portainer/docker-compose.portainer.yml up -d + +# ์ƒํƒœ ํ™•์ธ +docker-compose -f portainer/docker-compose.portainer.yml ps +``` + +## ๐Ÿ” ๋ฐฐํฌ ํ™•์ธ + +### 1. ์„œ๋น„์Šค ์ƒํƒœ ํ™•์ธ +Portainer Dashboard โ†’ Containers์—์„œ ๋‹ค์Œ ์ปจํ…Œ์ด๋„ˆ๋“ค์ด ์‹คํ–‰ ์ค‘์ธ์ง€ ํ™•์ธ: +- โœ… stock-oracle-postgres +- โœ… stock-oracle-redis +- โœ… stock-oracle-api +- โœ… stock-oracle-nginx + +### 2. API ์ ‘์† ํ…Œ์ŠคํŠธ +```bash +# HTTP (๋ฆฌ๋‹ค์ด๋ ‰ํŠธ) +curl http://localhost/docs + +# HTTPS +curl -k https://localhost/docs + +# API ํ…Œ์ŠคํŠธ +curl -k https://localhost/api/v1/financial/data \ + -H "Content-Type: application/json" \ + -d '{"ticker":"AAPL","start_date":"2024-01-01","end_date":"2024-12-31","period_type":"quarterly"}' +``` + +### 3. ์ ‘์† URL +- **API ๋ฌธ์„œ**: https://localhost/docs +- **API ์—”๋“œํฌ์ธํŠธ**: https://localhost/api/v1/ +- **Portainer**: https://localhost:9443 + +## ๐Ÿ› ๏ธ ๊ด€๋ฆฌ ๋ฐ ๋ชจ๋‹ˆํ„ฐ๋ง + +### Portainer์—์„œ ์ œ๊ณตํ•˜๋Š” ๊ธฐ๋Šฅ + +1. **์ปจํ…Œ์ด๋„ˆ ๊ด€๋ฆฌ** + - ์ปจํ…Œ์ด๋„ˆ ์‹œ์ž‘/์ค‘์ง€/์žฌ์‹œ์ž‘ + - ๋กœ๊ทธ ์‹ค์‹œ๊ฐ„ ๋ชจ๋‹ˆํ„ฐ๋ง + - ๋ฆฌ์†Œ์Šค ์‚ฌ์šฉ๋Ÿ‰ ํ™•์ธ + +2. **๋ณผ๋ฅจ ๊ด€๋ฆฌ** + - ๋ฐ์ดํ„ฐ ๋ฐฑ์—…/๋ณต์› + - ๋ณผ๋ฅจ ํฌ๊ธฐ ๋ชจ๋‹ˆํ„ฐ๋ง + +3. **๋„คํŠธ์›Œํฌ ๊ด€๋ฆฌ** + - ๋„คํŠธ์›Œํฌ ์„ค์ • ํ™•์ธ + - ์ปจํ…Œ์ด๋„ˆ ๊ฐ„ ํ†ต์‹  ๊ด€๋ฆฌ + +4. **์Šคํƒ ์—…๋ฐ์ดํŠธ** + - Git pull์„ ํ†ตํ•œ ์ž๋™ ์—…๋ฐ์ดํŠธ + - Rolling update ์ง€์› + +### ์œ ์šฉํ•œ Portainer ๋ช…๋ น์–ด + +```bash +# ์Šคํƒ ์žฌ๋ฐฐํฌ +curl -X POST "http://localhost:9000/api/stacks/1/git/redeploy" \ + -H "X-API-Key: your-api-key" + +# ์ปจํ…Œ์ด๋„ˆ ์žฌ์‹œ์ž‘ +curl -X POST "http://localhost:9000/api/endpoints/1/docker/containers/container-id/restart" \ + -H "X-API-Key: your-api-key" +``` + +## ๐Ÿ”ง ๋ฌธ์ œ ํ•ด๊ฒฐ + +### ์ผ๋ฐ˜์ ์ธ ๋ฌธ์ œ๋“ค + +1. **ํฌํŠธ ์ถฉ๋Œ** + ```bash + # ํฌํŠธ ์‚ฌ์šฉ ํ™•์ธ + netstat -tlnp | grep :18001 + + # ํ™˜๊ฒฝ ๋ณ€์ˆ˜์—์„œ ํฌํŠธ ๋ณ€๊ฒฝ + API_PORT=18002 + ``` + +2. **๊ถŒํ•œ ๋ฌธ์ œ** + ```bash + # Docker ์†Œ์ผ“ ๊ถŒํ•œ + sudo chmod 666 /var/run/docker.sock + + # SSL ์ธ์ฆ์„œ ๊ถŒํ•œ + chmod 600 portainer/nginx/ssl/key.pem + ``` + +3. **๋ฉ”๋ชจ๋ฆฌ ๋ถ€์กฑ** + ```bash + # ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ํ™•์ธ + docker stats + + # ๋ถˆํ•„์š”ํ•œ ์ปจํ…Œ์ด๋„ˆ ์ •๋ฆฌ + docker system prune -a + ``` + +### ๋กœ๊ทธ ํ™•์ธ ๋ฐฉ๋ฒ• + +```bash +# Portainer์—์„œ ๋กœ๊ทธ ํ™•์ธ +Containers โ†’ ์ปจํ…Œ์ด๋„ˆ ์„ ํƒ โ†’ Logs + +# ๋ช…๋ น์–ด๋กœ ๋กœ๊ทธ ํ™•์ธ +docker logs stock-oracle-api +docker logs stock-oracle-postgres +docker logs stock-oracle-nginx +``` + +## ๐Ÿ”„ ์—…๋ฐ์ดํŠธ ๋ฐ ๋ฐฑ์—… + +### ์—…๋ฐ์ดํŠธ ๋ฐฉ๋ฒ• +1. Portainer โ†’ Stacks โ†’ stock-oracle โ†’ Editor +2. Git Repository ๋ฐฉ๋ฒ•: **Pull and redeploy** ํด๋ฆญ +3. ๋˜๋Š” ์ƒˆ ์ด๋ฏธ์ง€๋กœ ์ˆ˜๋™ ์—…๋ฐ์ดํŠธ + +### ๋ฐฑ์—… ๋ฐฉ๋ฒ• +```bash +# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ฐฑ์—… +docker exec stock-oracle-postgres pg_dump \ + -U stock_oracle_user stock_oracle > backup.sql + +# ๋ณผ๋ฅจ ๋ฐฑ์—… +docker run --rm -v stock-oracle-postgres-data:/data \ + -v $(pwd):/backup alpine tar czf /backup/postgres-backup.tar.gz /data +``` + +## ๐ŸŽ‰ ๋ฐฐํฌ ์™„๋ฃŒ! + +๋ฐฐํฌ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜๋ฉด: +- ๐Ÿ“Š **API ๋ฌธ์„œ**: https://localhost/docs +- ๐Ÿ”ง **Portainer ๊ด€๋ฆฌ**: https://localhost:9443 +- ๐Ÿงช **ํ…Œ์ŠคํŠธ ์‹คํ–‰**: `python tests/run_all_tests.py` + +### ๋‹ค์Œ ๋‹จ๊ณ„ +1. ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์—์„œ๋Š” Let's Encrypt SSL ์ธ์ฆ์„œ ์‚ฌ์šฉ +2. ๋„๋ฉ”์ธ ์„ค์ • ๋ฐ DNS ๊ตฌ์„ฑ +3. ๋ชจ๋‹ˆํ„ฐ๋ง ์‹œ์Šคํ…œ ์—ฐ๋™ +4. ์ž๋™ ๋ฐฑ์—… ์Šค์ผ€์ค„ ์„ค์ • \ No newline at end of file diff --git a/portainer/docker-compose.full-stack.yml b/portainer/docker-compose.full-stack.yml new file mode 100644 index 0000000..eef5d93 --- /dev/null +++ b/portainer/docker-compose.full-stack.yml @@ -0,0 +1,156 @@ +version: '3.8' + +services: + # PostgreSQL Database + postgres: + image: postgres:15 + container_name: stock-oracle-postgres + environment: + POSTGRES_DB: stock_oracle + POSTGRES_USER: stock_oracle_user + POSTGRES_PASSWORD: stock_oracle_password + TZ: Asia/Seoul + volumes: + - postgres_data:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro + ports: + - "15433:5432" + networks: + - stock-oracle-network + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U stock_oracle_user -d stock_oracle"] + interval: 30s + timeout: 10s + retries: 3 + + # Redis Cache + redis: + image: redis:7-alpine + container_name: stock-oracle-redis + command: redis-server --appendonly yes --requirepass redis_password + volumes: + - redis_data:/data + ports: + - "16380:6379" + networks: + - stock-oracle-network + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "-a", "redis_password", "ping"] + interval: 30s + timeout: 10s + retries: 3 + + # Stock Oracle API + api: + image: stock-oracle-api:latest + container_name: stock-oracle-api + build: + context: .. + dockerfile: Dockerfile + environment: + # Database Configuration + DATABASE_URL: postgresql+asyncpg://stock_oracle_user:stock_oracle_password@postgres:5432/stock_oracle + + # Redis Configuration + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: redis_password + + # API Configuration + API_TITLE: Stock Oracle API + API_VERSION: 1.0.0 + DEBUG: false + + # External API Keys + SEC_EMAIL: ${SEC_EMAIL:-your-email@example.com} + ALPHA_VANTAGE_API_KEY: ${ALPHA_VANTAGE_API_KEY:-} + + # Timezone + TZ: Asia/Seoul + + ports: + - "18001:8000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - stock-oracle-network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/docs"] + interval: 30s + timeout: 10s + retries: 3 + volumes: + - api_logs:/app/logs + - yfinance_plus_src:/app/yfinance_plus + + # Stock Oracle Frontend + frontend: + image: stock-oracle-frontend:latest + container_name: stock-oracle-frontend + build: + context: ../frontend + dockerfile: Dockerfile + environment: + # API Configuration + NEXT_PUBLIC_API_URL: http://api:8000/api/v1 + + # Application Configuration + NEXT_PUBLIC_APP_NAME: Stock Oracle + NEXT_PUBLIC_APP_VERSION: 1.0.0 + + # Node Environment + NODE_ENV: production + + ports: + - "3000:3000" + depends_on: + - api + networks: + - stock-oracle-network + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000"] + interval: 30s + timeout: 10s + retries: 3 + + # Nginx Reverse Proxy + nginx: + image: nginx:alpine + container_name: stock-oracle-nginx + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx-full.conf:/etc/nginx/nginx.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + - nginx_logs:/var/log/nginx + depends_on: + - api + - frontend + networks: + - stock-oracle-network + restart: unless-stopped + +volumes: + postgres_data: + name: stock-oracle-postgres-data + redis_data: + name: stock-oracle-redis-data + api_logs: + name: stock-oracle-api-logs + yfinance_plus_src: + name: stock-oracle-yfinance-plus + nginx_logs: + name: stock-oracle-nginx-logs + +networks: + stock-oracle-network: + name: stock-oracle-network + driver: bridge \ No newline at end of file diff --git a/portainer/docker-compose.portainer.yml b/portainer/docker-compose.portainer.yml new file mode 100644 index 0000000..fd5d2a3 --- /dev/null +++ b/portainer/docker-compose.portainer.yml @@ -0,0 +1,133 @@ +version: '3.8' + +services: + # PostgreSQL Database + postgres: + image: postgres:15 + container_name: stock-oracle-postgres + environment: + POSTGRES_DB: stock_oracle + POSTGRES_USER: stock_oracle_user + POSTGRES_PASSWORD: stock_oracle_password + TZ: Asia/Seoul + volumes: + - postgres_data:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro + ports: + - "15433:5432" + networks: + - stock-oracle-network + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U stock_oracle_user -d stock_oracle"] + interval: 30s + timeout: 10s + retries: 3 + + # Redis Cache + redis: + image: redis:7-alpine + container_name: stock-oracle-redis + command: redis-server --appendonly yes --requirepass redis_password + volumes: + - redis_data:/data + ports: + - "16380:6379" + networks: + - stock-oracle-network + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "-a", "redis_password", "ping"] + interval: 30s + timeout: 10s + retries: 3 + + # Stock Oracle API + api: + image: stock-oracle-api:latest + container_name: stock-oracle-api + build: + context: . + dockerfile: Dockerfile + environment: + # Database Configuration + DATABASE_URL: postgresql+asyncpg://stock_oracle_user:stock_oracle_password@postgres:5432/stock_oracle + + # Redis Configuration + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: redis_password + + # API Configuration + API_TITLE: Stock Oracle API + API_VERSION: 1.0.0 + DEBUG: false + + # External API Keys (ํ™˜๊ฒฝ๋ณ€์ˆ˜๋กœ ์„ค์ •) + SEC_EMAIL: ${SEC_EMAIL:-your-email@example.com} + ALPHA_VANTAGE_API_KEY: ${ALPHA_VANTAGE_API_KEY:-} + + # Timezone + TZ: Asia/Seoul + + ports: + - "18001:8000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - stock-oracle-network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/docs"] + interval: 30s + timeout: 10s + retries: 3 + volumes: + # ๋กœ๊ทธ ๋ณผ๋ฅจ ๋งˆ์šดํŠธ + - api_logs:/app/logs + # yfinance_plus ์†Œ์Šค ๋งˆ์šดํŠธ (์—…๋ฐ์ดํŠธ์šฉ) + - yfinance_plus_src:/app/yfinance_plus + + # Nginx Reverse Proxy (์„ ํƒ์‚ฌํ•ญ) + nginx: + image: nginx:alpine + container_name: stock-oracle-nginx + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + - nginx_logs:/var/log/nginx + depends_on: + - api + networks: + - stock-oracle-network + restart: unless-stopped + +volumes: + postgres_data: + name: stock-oracle-postgres-data + redis_data: + name: stock-oracle-redis-data + api_logs: + name: stock-oracle-api-logs + yfinance_plus_src: + name: stock-oracle-yfinance-plus + nginx_logs: + name: stock-oracle-nginx-logs + +networks: + stock-oracle-network: + name: stock-oracle-network + driver: bridge + +# Portainer Labels for better management +x-portainer-labels: &portainer-labels + - "io.portainer.accesscontrol.teams=stock-oracle-team" + - "io.portainer.accesscontrol.users=admin" + - "project=stock-oracle" + - "environment=production" \ No newline at end of file diff --git a/portainer/generate-ssl.sh b/portainer/generate-ssl.sh new file mode 100755 index 0000000..baa6021 --- /dev/null +++ b/portainer/generate-ssl.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# ๊ฐœ๋ฐœ์šฉ ์ž์ฒด ์„œ๋ช… SSL ์ธ์ฆ์„œ ์ƒ์„ฑ + +echo "๐Ÿ” SSL ์ธ์ฆ์„œ ์ƒ์„ฑ ์ค‘..." + +# SSL ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ +mkdir -p portainer/nginx/ssl + +# ์ž์ฒด ์„œ๋ช… ์ธ์ฆ์„œ ์ƒ์„ฑ +openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout portainer/nginx/ssl/key.pem \ + -out portainer/nginx/ssl/cert.pem \ + -subj "/C=KR/ST=Seoul/L=Seoul/O=StockOracle/OU=Development/CN=localhost" + +# ๊ถŒํ•œ ์„ค์ • +chmod 600 portainer/nginx/ssl/key.pem +chmod 644 portainer/nginx/ssl/cert.pem + +echo "โœ… SSL ์ธ์ฆ์„œ ์ƒ์„ฑ ์™„๋ฃŒ!" +echo "๐Ÿ“ ์œ„์น˜: portainer/nginx/ssl/" +echo "โš ๏ธ ์ด ์ธ์ฆ์„œ๋Š” ๊ฐœ๋ฐœ์šฉ์ž…๋‹ˆ๋‹ค. ํ”„๋กœ๋•์…˜์—์„œ๋Š” Let's Encrypt๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”." \ No newline at end of file diff --git a/portainer/install-portainer.sh b/portainer/install-portainer.sh new file mode 100755 index 0000000..c41d30f --- /dev/null +++ b/portainer/install-portainer.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +# Portainer ์„ค์น˜ ์Šคํฌ๋ฆฝํŠธ + +echo "๐Ÿณ Portainer ์„ค์น˜ ์‹œ์ž‘..." + +# Portainer ๋ณผ๋ฅจ ์ƒ์„ฑ +echo "๐Ÿ“ฆ Portainer ๋ฐ์ดํ„ฐ ๋ณผ๋ฅจ ์ƒ์„ฑ..." +docker volume create portainer_data + +# Portainer ์ปจํ…Œ์ด๋„ˆ ์‹คํ–‰ +echo "๐Ÿš€ Portainer ์ปจํ…Œ์ด๋„ˆ ์‹œ์ž‘..." +docker run -d \ + -p 8000:8000 \ + -p 9443:9443 \ + --name portainer \ + --restart=always \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v portainer_data:/data \ + portainer/portainer-ce:latest + +echo "โœ… Portainer ์„ค์น˜ ์™„๋ฃŒ!" +echo "" +echo "๐ŸŒ ์ ‘์† ์ •๋ณด:" +echo " - HTTPS: https://localhost:9443" +echo " - HTTP: http://localhost:8000" +echo "" +echo "๐Ÿ“ ์ฒซ ์‹คํ–‰ ์‹œ ๊ด€๋ฆฌ์ž ๊ณ„์ •์„ ๋งŒ๋“ค์–ด์ฃผ์„ธ์š”." +echo "โฐ 5๋ถ„ ์ด๋‚ด์— ์ ‘์†ํ•˜์ง€ ์•Š์œผ๋ฉด ์„ค์ •์ด ๋งŒ๋ฃŒ๋ฉ๋‹ˆ๋‹ค." + +# ์ปจํ…Œ์ด๋„ˆ ์ƒํƒœ ํ™•์ธ +echo "" +echo "๐Ÿ” Portainer ์ปจํ…Œ์ด๋„ˆ ์ƒํƒœ:" +docker ps | grep portainer \ No newline at end of file diff --git a/portainer/nginx/nginx-full.conf b/portainer/nginx/nginx-full.conf new file mode 100644 index 0000000..37b49ec --- /dev/null +++ b/portainer/nginx/nginx-full.conf @@ -0,0 +1,169 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # ๋กœ๊ทธ ํฌ๋งท + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + # Gzip ์••์ถ• + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml+rss + application/atom+xml + image/svg+xml; + + # Rate Limiting + limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; + limit_req_zone $binary_remote_addr zone=web:10m rate=30r/s; + + # Upstream for Stock Oracle API + upstream stock_oracle_api { + server api:8000; + } + + # Upstream for Stock Oracle Frontend + upstream stock_oracle_frontend { + server frontend:3000; + } + + # HTTP Server (redirect to HTTPS) + server { + listen 80; + server_name localhost stock-oracle.local; + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # Redirect all HTTP to HTTPS + location / { + return 301 https://$server_name$request_uri; + } + } + + # HTTPS Server + server { + listen 443 ssl http2; + server_name localhost stock-oracle.local; + + # SSL Configuration + ssl_certificate /etc/nginx/ssl/cert.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:50m; + ssl_session_tickets off; + + # Modern configuration + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + # HSTS + add_header Strict-Transport-Security "max-age=63072000" always; + + # Frontend (React App) + location / { + limit_req zone=web burst=50 nodelay; + + proxy_pass http://stock_oracle_frontend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket support for Next.js hot reload (development only) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_cache_bypass $http_upgrade; + } + + # API Endpoints + location /api/ { + limit_req zone=api burst=20 nodelay; + + proxy_pass http://stock_oracle_api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # CORS Headers + add_header Access-Control-Allow-Origin *; + add_header Access-Control-Allow-Methods "GET, POST, OPTIONS"; + add_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"; + + # Handle preflight requests + if ($request_method = 'OPTIONS') { + add_header Access-Control-Allow-Origin *; + add_header Access-Control-Allow-Methods "GET, POST, OPTIONS"; + add_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"; + add_header Access-Control-Max-Age 1728000; + add_header Content-Type 'text/plain charset=UTF-8'; + add_header Content-Length 0; + return 204; + } + } + + # Direct API Documentation access + location /docs { + proxy_pass http://stock_oracle_api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Redoc Documentation + location /redoc { + proxy_pass http://stock_oracle_api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Frontend static assets with caching + location /_next/static/ { + proxy_pass http://stock_oracle_frontend; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Frontend images with caching + location /images/ { + proxy_pass http://stock_oracle_frontend; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Frontend favicon + location /favicon.ico { + proxy_pass http://stock_oracle_frontend; + expires 1y; + add_header Cache-Control "public, immutable"; + } + } +} \ No newline at end of file diff --git a/portainer/nginx/nginx.conf b/portainer/nginx/nginx.conf new file mode 100644 index 0000000..215d707 --- /dev/null +++ b/portainer/nginx/nginx.conf @@ -0,0 +1,144 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # ๋กœ๊ทธ ํฌ๋งท + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + # Gzip ์••์ถ• + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml+rss + application/atom+xml + image/svg+xml; + + # Rate Limiting + limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; + + # Upstream for Stock Oracle API + upstream stock_oracle_api { + server api:8000; + } + + # HTTP Server (redirect to HTTPS) + server { + listen 80; + server_name localhost stock-oracle.local; + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # Redirect all HTTP to HTTPS + location / { + return 301 https://$server_name$request_uri; + } + } + + # HTTPS Server + server { + listen 443 ssl http2; + server_name localhost stock-oracle.local; + + # SSL Configuration + ssl_certificate /etc/nginx/ssl/cert.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:50m; + ssl_session_tickets off; + + # Modern configuration + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + # HSTS + add_header Strict-Transport-Security "max-age=63072000" always; + + # API Endpoints + location /api/ { + limit_req zone=api burst=20 nodelay; + + proxy_pass http://stock_oracle_api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # CORS Headers + add_header Access-Control-Allow-Origin *; + add_header Access-Control-Allow-Methods "GET, POST, OPTIONS"; + add_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"; + + # Handle preflight requests + if ($request_method = 'OPTIONS') { + add_header Access-Control-Allow-Origin *; + add_header Access-Control-Allow-Methods "GET, POST, OPTIONS"; + add_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"; + add_header Access-Control-Max-Age 1728000; + add_header Content-Type 'text/plain charset=UTF-8'; + add_header Content-Length 0; + return 204; + } + } + + # API Documentation + location /docs { + proxy_pass http://stock_oracle_api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Redoc Documentation + location /redoc { + proxy_pass http://stock_oracle_api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Health Check + location /health { + access_log off; + proxy_pass http://stock_oracle_api/docs; + proxy_set_header Host $host; + } + + # Static files (if any) + location /static/ { + alias /var/www/static/; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Root redirect + location = / { + return 302 /docs; + } + } +} \ No newline at end of file diff --git a/portainer/quick-deploy.sh b/portainer/quick-deploy.sh new file mode 100755 index 0000000..1479d42 --- /dev/null +++ b/portainer/quick-deploy.sh @@ -0,0 +1,179 @@ +#!/bin/bash + +# Stock Oracle Portainer ๋น ๋ฅธ ๋ฐฐํฌ ์Šคํฌ๋ฆฝํŠธ + +set -e + +echo "๐Ÿš€ Stock Oracle Portainer ๋น ๋ฅธ ๋ฐฐํฌ ์‹œ์ž‘..." + +# ์ƒ‰์ƒ ์ •์˜ +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# ํ•จ์ˆ˜ ์ •์˜ +print_status() { + echo -e "${BLUE}โ„น๏ธ $1${NC}" +} + +print_success() { + echo -e "${GREEN}โœ… $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}โš ๏ธ $1${NC}" +} + +print_error() { + echo -e "${RED}โŒ $1${NC}" +} + +# Step 1: ํ•„์ˆ˜ ๋„๊ตฌ ํ™•์ธ +print_status "ํ•„์ˆ˜ ๋„๊ตฌ ํ™•์ธ ์ค‘..." + +if ! command -v docker &> /dev/null; then + print_error "Docker๊ฐ€ ์„ค์น˜๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค." + exit 1 +fi + +if ! command -v docker-compose &> /dev/null; then + print_error "Docker Compose๊ฐ€ ์„ค์น˜๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค." + exit 1 +fi + +print_success "Docker ๋ฐ Docker Compose ์„ค์น˜ ํ™•์ธ" + +# Step 2: ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ํ™•์ธ +print_status "ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ํ™•์ธ ์ค‘..." + +if [ -z "$SEC_EMAIL" ]; then + print_warning "SEC_EMAIL ํ™˜๊ฒฝ ๋ณ€์ˆ˜๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค." + read -p "SEC API ์ด๋ฉ”์ผ ์ฃผ์†Œ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”: " SEC_EMAIL + export SEC_EMAIL +fi + +print_success "ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ • ์™„๋ฃŒ" + +# Step 3: Portainer ์„ค์น˜ ํ™•์ธ +print_status "Portainer ์ƒํƒœ ํ™•์ธ ์ค‘..." + +if ! docker ps | grep -q portainer; then + print_warning "Portainer๊ฐ€ ์‹คํ–‰๋˜์ง€ ์•Š๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์„ค์น˜๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค..." + ./portainer/install-portainer.sh + + print_status "Portainer ์ดˆ๊ธฐํ™”๋ฅผ ์œ„ํ•ด 30์ดˆ ๋Œ€๊ธฐ ์ค‘..." + sleep 30 +else + print_success "Portainer๊ฐ€ ์ด๋ฏธ ์‹คํ–‰ ์ค‘์ž…๋‹ˆ๋‹ค." +fi + +# Step 4: SSL ์ธ์ฆ์„œ ์ƒ์„ฑ +print_status "SSL ์ธ์ฆ์„œ ํ™•์ธ ์ค‘..." + +if [ ! -f "portainer/nginx/ssl/cert.pem" ]; then + print_warning "SSL ์ธ์ฆ์„œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ์ƒ์„ฑ์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค..." + ./portainer/generate-ssl.sh +else + print_success "SSL ์ธ์ฆ์„œ๊ฐ€ ์ด๋ฏธ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค." +fi + +# Step 5: ํ™˜๊ฒฝ ํŒŒ์ผ ์ƒ์„ฑ +print_status "ํ™˜๊ฒฝ ํŒŒ์ผ ์„ค์ • ์ค‘..." + +if [ ! -f ".env" ]; then + cp portainer/.env.portainer .env + + # ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์ž๋™ ์„ค์ • + sed -i.bak "s/your-email@example.com/$SEC_EMAIL/g" .env + + # ๋ณด์•ˆ ํŒจ์Šค์›Œ๋“œ ์ƒ์„ฑ + POSTGRES_PASS=$(openssl rand -base64 32 | tr -d /=+ | cut -c -16) + REDIS_PASS=$(openssl rand -base64 32 | tr -d /=+ | cut -c -16) + + sed -i.bak "s/change_this_password_in_production/$POSTGRES_PASS/g" .env + sed -i.bak "s/change_this_redis_password/$REDIS_PASS/g" .env + + rm .env.bak + + print_success "ํ™˜๊ฒฝ ํŒŒ์ผ ์ƒ์„ฑ ๋ฐ ์„ค์ • ์™„๋ฃŒ" +else + print_success "ํ™˜๊ฒฝ ํŒŒ์ผ์ด ์ด๋ฏธ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค." +fi + +# Step 6: Docker ์ด๋ฏธ์ง€ ๋นŒ๋“œ +print_status "Docker ์ด๋ฏธ์ง€ ๋นŒ๋“œ ์ค‘..." + +docker build -t stock-oracle-api:latest . + +print_success "Docker ์ด๋ฏธ์ง€ ๋นŒ๋“œ ์™„๋ฃŒ" + +# Step 7: ์Šคํƒ ๋ฐฐํฌ +print_status "Stock Oracle ์Šคํƒ ๋ฐฐํฌ ์ค‘..." + +docker-compose -f portainer/docker-compose.portainer.yml down --remove-orphans +docker-compose -f portainer/docker-compose.portainer.yml up -d + +print_success "์Šคํƒ ๋ฐฐํฌ ์™„๋ฃŒ" + +# Step 8: ์„œ๋น„์Šค ์ƒํƒœ ํ™•์ธ +print_status "์„œ๋น„์Šค ์ƒํƒœ ํ™•์ธ ์ค‘..." + +sleep 10 + +# ์„œ๋น„์Šค ํ—ฌ์Šค์ฒดํฌ +services=("stock-oracle-postgres" "stock-oracle-redis" "stock-oracle-api" "stock-oracle-nginx") +all_healthy=true + +for service in "${services[@]}"; do + if docker ps --format "table {{.Names}}\t{{.Status}}" | grep -q "$service.*Up"; then + print_success "$service: ์‹คํ–‰ ์ค‘" + else + print_error "$service: ์‹คํ–‰ ์‹คํŒจ" + all_healthy=false + fi +done + +# Step 9: API ์ ‘์† ํ…Œ์ŠคํŠธ +print_status "API ์ ‘์† ํ…Œ์ŠคํŠธ ์ค‘..." + +sleep 15 + +if curl -k -s https://localhost/docs > /dev/null; then + print_success "API ์„œ๋ฒ„ ์ ‘์† ์„ฑ๊ณต" +else + print_error "API ์„œ๋ฒ„ ์ ‘์† ์‹คํŒจ" + all_healthy=false +fi + +# Step 10: ๊ฒฐ๊ณผ ์ถœ๋ ฅ +echo "" +echo "==================================" +echo "๐ŸŽ‰ Stock Oracle ๋ฐฐํฌ ๊ฒฐ๊ณผ" +echo "==================================" + +if [ "$all_healthy" = true ]; then + print_success "๋ชจ๋“  ์„œ๋น„์Šค๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ๋ฐฐํฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!" + echo "" + echo "๐Ÿ“Š ์ ‘์† ์ •๋ณด:" + echo " - API ๋ฌธ์„œ: https://localhost/docs" + echo " - Portainer: https://localhost:9443" + echo " - API ์—”๋“œํฌ์ธํŠธ: https://localhost/api/v1/" + echo "" + echo "๐Ÿงช ํ…Œ์ŠคํŠธ ์‹คํ–‰:" + echo " python tests/run_all_tests.py" + echo "" + echo "๐Ÿ”ง ๊ด€๋ฆฌ:" + echo " - ์ปจํ…Œ์ด๋„ˆ ์ƒํƒœ: docker-compose -f portainer/docker-compose.portainer.yml ps" + echo " - ๋กœ๊ทธ ํ™•์ธ: docker-compose -f portainer/docker-compose.portainer.yml logs" + echo " - ์ค‘์ง€: docker-compose -f portainer/docker-compose.portainer.yml down" +else + print_error "์ผ๋ถ€ ์„œ๋น„์Šค ๋ฐฐํฌ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค." + echo "" + echo "๐Ÿ” ๋ฌธ์ œ ํ•ด๊ฒฐ:" + echo " - ๋กœ๊ทธ ํ™•์ธ: docker-compose -f portainer/docker-compose.portainer.yml logs" + echo " - ์ƒํƒœ ํ™•์ธ: docker-compose -f portainer/docker-compose.portainer.yml ps" + echo " - ์žฌ์‹œ์ž‘: docker-compose -f portainer/docker-compose.portainer.yml restart" + exit 1 +fi \ No newline at end of file diff --git a/portainer/stack-template.json b/portainer/stack-template.json new file mode 100644 index 0000000..480c3e9 --- /dev/null +++ b/portainer/stack-template.json @@ -0,0 +1,104 @@ +{ + "version": "2", + "templates": [ + { + "type": 3, + "title": "Stock Oracle API", + "description": "์™„์ „ํ•œ ์ฃผ์‹ ๋ฐ์ดํ„ฐ ๋ถ„์„ API with PostgreSQL, Redis, Nginx", + "note": "์‹ค์ œ SEC ์žฌ๋ฌด ๋ฐ์ดํ„ฐ๋ฅผ ์ œ๊ณตํ•˜๋Š” ์ฃผ์‹ ๋ถ„์„ API ์‹œ์Šคํ…œ", + "categories": ["API", "Finance", "Analytics"], + "platform": "linux", + "logo": "https://raw.githubusercontent.com/portainer/portainer/develop/app/assets/ico/favicon.ico", + "repository": { + "url": "https://github.com/your-repo/stock-oracle", + "stackfile": "portainer/docker-compose.portainer.yml" + }, + "env": [ + { + "name": "SEC_EMAIL", + "label": "SEC API ์ด๋ฉ”์ผ", + "description": "SEC ๋ฐ์ดํ„ฐ ์ ‘๊ทผ์„ ์œ„ํ•œ ์ด๋ฉ”์ผ ์ฃผ์†Œ (ํ•„์ˆ˜)", + "default": "your-email@example.com" + }, + { + "name": "ALPHA_VANTAGE_API_KEY", + "label": "Alpha Vantage API Key", + "description": "Alpha Vantage API ํ‚ค (์„ ํƒ์‚ฌํ•ญ)", + "default": "" + }, + { + "name": "POSTGRES_PASSWORD", + "label": "PostgreSQL ํŒจ์Šค์›Œ๋“œ", + "description": "PostgreSQL ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํŒจ์Šค์›Œ๋“œ", + "default": "change_this_password" + }, + { + "name": "REDIS_PASSWORD", + "label": "Redis ํŒจ์Šค์›Œ๋“œ", + "description": "Redis ์บ์‹œ ํŒจ์Šค์›Œ๋“œ", + "default": "change_this_redis_password" + }, + { + "name": "API_PORT", + "label": "API ํฌํŠธ", + "description": "Stock Oracle API ํฌํŠธ", + "default": "18001" + }, + { + "name": "POSTGRES_PORT", + "label": "PostgreSQL ํฌํŠธ", + "description": "PostgreSQL ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํฌํŠธ", + "default": "15433" + }, + { + "name": "REDIS_PORT", + "label": "Redis ํฌํŠธ", + "description": "Redis ์บ์‹œ ํฌํŠธ", + "default": "16380" + }, + { + "name": "DEBUG", + "label": "๋””๋ฒ„๊ทธ ๋ชจ๋“œ", + "description": "๊ฐœ๋ฐœ์šฉ ๋””๋ฒ„๊ทธ ๋ชจ๋“œ ํ™œ์„ฑํ™”", + "default": "false", + "select": [ + { + "text": "False", + "value": "false", + "default": true + }, + { + "text": "True", + "value": "true" + } + ] + } + ], + "volumes": [ + { + "container": "/var/lib/postgresql/data", + "bind": "stock-oracle-postgres-data" + }, + { + "container": "/data", + "bind": "stock-oracle-redis-data" + }, + { + "container": "/app/logs", + "bind": "stock-oracle-api-logs" + }, + { + "container": "/var/log/nginx", + "bind": "stock-oracle-nginx-logs" + } + ], + "ports": [ + "80:80/tcp", + "443:443/tcp", + "18001:18001/tcp", + "15433:15433/tcp", + "16380:16380/tcp" + ] + } + ] +} \ No newline at end of file diff --git a/requirements-api.txt b/requirements-api.txt new file mode 100644 index 0000000..7588755 --- /dev/null +++ b/requirements-api.txt @@ -0,0 +1,42 @@ +fastapi>=0.104.0,<1.0.0 +uvicorn[standard]>=0.24.0 +sqlalchemy>=2.0.0,<3.0.0 +alembic>=1.12.0 +asyncpg>=0.29.0 +aiosqlite>=0.19.0 +greenlet>=2.0.0 +pydantic>=2.0.0,<3.0.0 +pydantic-settings>=2.0.0 +python-multipart>=0.0.6 +httpx>=0.25.0 +redis>=5.0.0,<6.0.0 +celery>=5.3.0 +python-jose[cryptography]>=3.3.0 +passlib[bcrypt]>=1.7.4 +python-dotenv>=1.0.0 +orjson>=3.9.0 +markdown>=3.5.0 + +# Web scraping and HTTP requests +curl_cffi>=0.6.0 +beautifulsoup4>=4.12.0 + +# SEC data processing - using direct API calls and EDGAR downloader +aiohttp>=3.8.0 +pandas>=2.0.0,<3.0.0 +numpy>=1.24.0,<3.0.0 +python-dateutil>=2.8.0 +sec-edgar-downloader>=5.0.0 +lxml>=4.9.0 + +# Yahoo Finance price data only - using yfinance-plus installed from git +# yfinance-plus will be installed via: git+https://gitea.yirugi.synology.me/yirugi/yfinance_plus.git +# Regular yfinance is needed as dependency for yfinance-plus +yfinance>=0.2.65 + +# Development & Testing +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +pytest-cov>=4.1.0 +httpx>=0.25.0 +faker>=20.0.0 diff --git a/requirements-client.txt b/requirements-client.txt new file mode 100644 index 0000000..b4b4224 --- /dev/null +++ b/requirements-client.txt @@ -0,0 +1,18 @@ +# Stock Oracle Python Client Requirements +# Install with: pip install -r requirements-client.txt + +# Core dependencies +requests>=2.31.0 +python-dateutil>=2.8.2 + +# Optional but recommended +pandas>=2.0.0 # For data manipulation +numpy>=1.24.0 # For numerical operations +matplotlib>=3.7.0 # For plotting +jupyter>=1.0.0 # For notebook examples + +# Development dependencies (optional) +pytest>=7.4.0 # For running tests +black>=23.0.0 # For code formatting +pylint>=2.17.0 # For code linting +mypy>=1.5.0 # For type checking \ No newline at end of file diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..aa2c840 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,8 @@ +# Test-specific requirements +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +pytest-cov>=4.1.0 +httpx>=0.25.0 +faker>=20.0.0 +pydantic-settings>=2.0.0 +aiosqlite>=0.19.0 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5ed1090 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +# Dependencies for direct SEC EDGAR API integration +aiohttp>=3.8.0 +pandas>=2.0.0 +numpy>=1.24.0 +python-dateutil>=2.8.0 + +# SEC EDGAR data processing +sec-edgar-downloader>=5.0.0 +beautifulsoup4>=4.12.0 +lxml>=4.9.0 \ No newline at end of file diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..65f6bae --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# Stock Oracle ๋น ๋ฅธ ํ…Œ์ŠคํŠธ ์‹คํ–‰ ์Šคํฌ๋ฆฝํŠธ + +echo "๐Ÿงช Stock Oracle ํ…Œ์ŠคํŠธ ์‹คํ–‰" +echo "==========================" + +# ์„œ๋ฒ„ ์ƒํƒœ ํ™•์ธ +echo "๐Ÿ” ์„œ๋ฒ„ ์ƒํƒœ ํ™•์ธ ์ค‘..." +if ! curl -s http://localhost:18001/docs > /dev/null; then + echo "โŒ API ์„œ๋ฒ„๊ฐ€ ์‹คํ–‰๋˜์ง€ ์•Š๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค." + echo " docker-compose up -d ๋ช…๋ น์œผ๋กœ ์„œ๋ฒ„๋ฅผ ์‹œ์ž‘ํ•ด์ฃผ์„ธ์š”." + exit 1 +fi + +echo "โœ… API ์„œ๋ฒ„ ์‹คํ–‰ ํ™•์ธ" +echo "" + +# ํ…Œ์ŠคํŠธ ์‹คํ–‰ +echo "๐Ÿš€ ํ…Œ์ŠคํŠธ ์Šค์œ„ํŠธ ์‹คํ–‰ ์ค‘..." +python tests/run_all_tests.py + +# ๊ฒฐ๊ณผ์— ๋”ฐ๋ฅธ ๋ฉ”์‹œ์ง€ +if [ $? -eq 0 ]; then + echo "" + echo "๐ŸŽ‰ ๋ชจ๋“  ํ…Œ์ŠคํŠธ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!" + echo " ์‹œ์Šคํ…œ์ด ์ •์ƒ์ ์œผ๋กœ ์ž‘๋™ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค." +else + echo "" + echo "โŒ ์ผ๋ถ€ ํ…Œ์ŠคํŠธ๊ฐ€ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค." + echo " ๋กœ๊ทธ๋ฅผ ํ™•์ธํ•˜์—ฌ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•ด์ฃผ์„ธ์š”." + exit 1 +fi \ No newline at end of file diff --git a/scripts/init_db.sql b/scripts/init_db.sql new file mode 100644 index 0000000..2fc080e --- /dev/null +++ b/scripts/init_db.sql @@ -0,0 +1,21 @@ +-- Initialize Stock Oracle Database +-- This file is automatically executed when PostgreSQL container starts + +-- Note: Database is already created by POSTGRES_DB environment variable + +-- Create user if not exists (PostgreSQL syntax) +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'stockoracle') THEN + CREATE ROLE stockoracle WITH LOGIN PASSWORD 'stockoracle2024'; + END IF; +END +$$; + +-- Grant permissions +GRANT ALL PRIVILEGES ON DATABASE stock_oracle TO stockoracle; + +-- Grant schema permissions +GRANT ALL ON SCHEMA public TO stockoracle; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO stockoracle; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO stockoracle; \ No newline at end of file diff --git a/stock_oracle_analyzer.py b/stock_oracle_analyzer.py new file mode 100644 index 0000000..334ac09 --- /dev/null +++ b/stock_oracle_analyzer.py @@ -0,0 +1,531 @@ +""" +Stock Oracle - Investment Data Analyzer +Python client integration with Stock Oracle API for comprehensive financial analysis. +""" + +import pandas as pd +import numpy as np +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Union +import logging +import warnings +from stock_oracle_client import StockOracleClient, StockOracleAPIError + +warnings.filterwarnings('ignore') + +class StockOracleAnalyzer: + """ + Stock Oracle - Comprehensive financial data analyzer using Stock Oracle API + """ + + def __init__(self, api_url: str = "http://localhost:18001"): + """ + Initialize the analyzer with Stock Oracle API + + Args: + api_url: Stock Oracle API URL + """ + self.client = StockOracleClient(api_url) + self.logger = self._setup_logging() + + # Test connection + try: + health = self.client.get_health() + self.logger.info(f"Connected to Stock Oracle API: {health.get('status')}") + except Exception as e: + self.logger.error(f"Failed to connect to Stock Oracle API: {e}") + + def _setup_logging(self) -> logging.Logger: + """Set up logging configuration""" + logging.basicConfig(level=logging.INFO) + return logging.getLogger(__name__) + + def get_company_data(self, ticker: str, period: str = "5y") -> Dict: + """ + Retrieve comprehensive company data from Stock Oracle API + + Args: + ticker: Stock ticker symbol + period: Time period for data (e.g., "1y", "3y", "5y") + + Returns: + Dict containing financial data, price data, and analysis + """ + try: + self.logger.info(f"Retrieving data for {ticker}") + + # Get financial data + financial_data = self.client.get_financial_data( + ticker=ticker, + period=period, + include_metrics=True + ) + + # Get price data + price_data = self.client.get_price_data( + ticker=ticker, + period=period + ) + + # Get news and social media data + try: + news_social_data = self.client.get_news_social_data( + ticker=ticker, + days_back=30, + max_articles=50 + ) + except Exception as e: + self.logger.warning(f"Could not retrieve news/social data: {e}") + news_social_data = None + + # Combine all data + data = { + 'ticker': ticker, + 'company_name': financial_data.get('company', {}).get('name'), + 'financial_data': financial_data, + 'price_data': price_data, + 'news_social_data': news_social_data, + 'analysis_summary': self._generate_analysis_summary(financial_data, price_data, news_social_data) + } + + return data + + except StockOracleAPIError as e: + self.logger.error(f"API Error retrieving data for {ticker}: {str(e)}") + return None + except Exception as e: + self.logger.error(f"Error retrieving data for {ticker}: {str(e)}") + return None + + def _generate_analysis_summary(self, financial_data: Dict, price_data: Dict, news_social_data: Optional[Dict]) -> Dict: + """Generate analysis summary from all available data""" + + summary = { + 'financial_health': self._assess_financial_health(financial_data), + 'price_trends': self._analyze_price_trends(price_data), + 'sentiment_analysis': self._analyze_sentiment(news_social_data) if news_social_data else None, + 'key_metrics': self._extract_key_metrics(financial_data), + 'investment_score': None # Will be calculated based on all factors + } + + # Calculate overall investment score (0-100) + summary['investment_score'] = self._calculate_investment_score(summary) + + return summary + + def _assess_financial_health(self, financial_data: Dict) -> Dict: + """Assess financial health based on financial metrics""" + + health_score = 0 + max_score = 100 + assessment = {} + + try: + latest_data = financial_data.get('financial_data', []) + if not latest_data: + return {'score': 0, 'assessment': 'No financial data available'} + + latest = latest_data[0] # Most recent data + + # Revenue growth assessment + if len(latest_data) >= 2: + current_revenue = latest.get('revenue', 0) + previous_revenue = latest_data[1].get('revenue', 0) + if previous_revenue > 0: + revenue_growth = (current_revenue - previous_revenue) / previous_revenue + assessment['revenue_growth'] = revenue_growth + if revenue_growth > 0.15: # >15% growth + health_score += 25 + elif revenue_growth > 0.05: # >5% growth + health_score += 15 + elif revenue_growth > 0: # Positive growth + health_score += 10 + + # Profitability assessment + if 'pe_ratio' in latest and latest['pe_ratio'] and latest['pe_ratio'] > 0: + pe = latest['pe_ratio'] + assessment['pe_ratio'] = pe + if 10 <= pe <= 25: # Reasonable P/E range + health_score += 25 + elif 5 <= pe < 40: # Acceptable range + health_score += 15 + + # ROE assessment + if 'roe' in latest and latest['roe']: + roe = latest['roe'] + assessment['roe'] = roe + if roe > 0.20: # >20% ROE + health_score += 25 + elif roe > 0.15: # >15% ROE + health_score += 20 + elif roe > 0.10: # >10% ROE + health_score += 15 + + # Debt assessment + if 'debt_to_equity' in latest and latest['debt_to_equity']: + debt_to_equity = latest['debt_to_equity'] + assessment['debt_to_equity'] = debt_to_equity + if debt_to_equity < 0.3: # Low debt + health_score += 25 + elif debt_to_equity < 0.6: # Moderate debt + health_score += 15 + elif debt_to_equity < 1.0: # High but manageable debt + health_score += 5 + + except Exception as e: + self.logger.warning(f"Error in financial health assessment: {e}") + + return { + 'score': min(health_score, max_score), + 'assessment': assessment, + 'grade': self._score_to_grade(min(health_score, max_score)) + } + + def _analyze_price_trends(self, price_data: Dict) -> Dict: + """Analyze price trends and momentum""" + + try: + prices = price_data.get('price_data', []) + if len(prices) < 10: + return {'trend': 'insufficient_data'} + + # Get closing prices + closes = [p['close'] for p in prices] + dates = [p['date'] for p in prices] + + # Calculate basic metrics + current_price = closes[-1] + price_52w_high = max(closes) + price_52w_low = min(closes) + + # Calculate moving averages + ma_50 = np.mean(closes[-50:]) if len(closes) >= 50 else np.mean(closes) + ma_20 = np.mean(closes[-20:]) if len(closes) >= 20 else np.mean(closes) + + # Calculate price momentum (30-day return) + month_ago_price = closes[-30] if len(closes) >= 30 else closes[0] + momentum_30d = (current_price - month_ago_price) / month_ago_price + + # Calculate volatility (standard deviation) + returns = [(closes[i] - closes[i-1]) / closes[i-1] for i in range(1, len(closes))] + volatility = np.std(returns) * np.sqrt(252) # Annualized volatility + + # Determine trend + trend = 'neutral' + if current_price > ma_50 and ma_20 > ma_50: + trend = 'bullish' + elif current_price < ma_50 and ma_20 < ma_50: + trend = 'bearish' + + return { + 'trend': trend, + 'current_price': current_price, + 'price_52w_high': price_52w_high, + 'price_52w_low': price_52w_low, + 'from_52w_high': (current_price - price_52w_high) / price_52w_high, + 'from_52w_low': (current_price - price_52w_low) / price_52w_low, + 'ma_50': ma_50, + 'ma_20': ma_20, + 'momentum_30d': momentum_30d, + 'volatility': volatility + } + + except Exception as e: + self.logger.warning(f"Error in price trend analysis: {e}") + return {'trend': 'error', 'error': str(e)} + + def _analyze_sentiment(self, news_social_data: Dict) -> Dict: + """Analyze news and social media sentiment""" + + try: + news_articles = news_social_data.get('news', {}).get('articles', []) + social_posts = news_social_data.get('social_media', {}).get('posts', []) + + # Simple sentiment analysis based on keywords + positive_words = ['growth', 'profit', 'success', 'strong', 'beat', 'exceed', 'bullish', 'upgrade'] + negative_words = ['loss', 'decline', 'fall', 'weak', 'miss', 'bearish', 'downgrade', 'concern'] + + total_sentiment = 0 + total_items = 0 + + # Analyze news sentiment + for article in news_articles: + title = article.get('title', '').lower() + summary = article.get('summary', '').lower() + text = f"{title} {summary}" + + sentiment = 0 + for word in positive_words: + sentiment += text.count(word) + for word in negative_words: + sentiment -= text.count(word) + + total_sentiment += sentiment + total_items += 1 + + # Analyze social media sentiment (including Reddit scores) + social_sentiment = 0 + for post in social_posts: + title = post.get('title', '').lower() + content = post.get('content', '').lower() + text = f"{title} {content}" + + sentiment = 0 + for word in positive_words: + sentiment += text.count(word) + for word in negative_words: + sentiment -= text.count(word) + + # Factor in Reddit score if available + score = post.get('score', 0) + if score > 100: + sentiment += 1 # High-scoring posts are generally positive + elif score < -10: + sentiment -= 1 + + social_sentiment += sentiment + total_items += 1 + + # Calculate overall sentiment + if total_items > 0: + overall_sentiment = (total_sentiment + social_sentiment) / total_items + else: + overall_sentiment = 0 + + # Classify sentiment + if overall_sentiment > 0.5: + sentiment_label = 'positive' + elif overall_sentiment < -0.5: + sentiment_label = 'negative' + else: + sentiment_label = 'neutral' + + return { + 'overall_sentiment': overall_sentiment, + 'sentiment_label': sentiment_label, + 'news_articles_count': len(news_articles), + 'social_posts_count': len(social_posts), + 'total_items_analyzed': total_items + } + + except Exception as e: + self.logger.warning(f"Error in sentiment analysis: {e}") + return {'sentiment_label': 'error', 'error': str(e)} + + def _extract_key_metrics(self, financial_data: Dict) -> Dict: + """Extract key financial metrics""" + + try: + latest_data = financial_data.get('financial_data', []) + if not latest_data: + return {} + + latest = latest_data[0] + + return { + 'revenue': latest.get('revenue'), + 'net_income': latest.get('net_income'), + 'eps': latest.get('eps'), + 'pe_ratio': latest.get('pe_ratio'), + 'pb_ratio': latest.get('pb_ratio'), + 'roe': latest.get('roe'), + 'roa': latest.get('roa'), + 'debt_to_equity': latest.get('debt_to_equity'), + 'market_cap': latest.get('market_cap'), + 'period_date': latest.get('period_date') + } + + except Exception as e: + self.logger.warning(f"Error extracting key metrics: {e}") + return {} + + def _calculate_investment_score(self, summary: Dict) -> int: + """Calculate overall investment score (0-100)""" + + try: + score = 0 + + # Financial health (40% weight) + financial_score = summary.get('financial_health', {}).get('score', 0) + score += financial_score * 0.4 + + # Price trends (30% weight) + price_trends = summary.get('price_trends', {}) + if price_trends.get('trend') == 'bullish': + score += 30 + elif price_trends.get('trend') == 'neutral': + score += 15 + # Bearish trend adds 0 + + # Sentiment (20% weight) + sentiment = summary.get('sentiment_analysis') + if sentiment: + if sentiment.get('sentiment_label') == 'positive': + score += 20 + elif sentiment.get('sentiment_label') == 'neutral': + score += 10 + # Negative sentiment adds 0 + + # Momentum bonus (10% weight) + momentum = price_trends.get('momentum_30d', 0) + if momentum > 0.10: # >10% monthly return + score += 10 + elif momentum > 0.05: # >5% monthly return + score += 7 + elif momentum > 0: # Positive return + score += 3 + + return min(int(score), 100) + + except Exception as e: + self.logger.warning(f"Error calculating investment score: {e}") + return 0 + + def _score_to_grade(self, score: int) -> str: + """Convert numeric score to letter grade""" + if score >= 90: + return 'A+' + elif score >= 85: + return 'A' + elif score >= 80: + return 'A-' + elif score >= 75: + return 'B+' + elif score >= 70: + return 'B' + elif score >= 65: + return 'B-' + elif score >= 60: + return 'C+' + elif score >= 55: + return 'C' + elif score >= 50: + return 'C-' + elif score >= 40: + return 'D' + else: + return 'F' + + def analyze_multiple_companies(self, tickers: List[str], period: str = "3y") -> Dict: + """Analyze multiple companies and return comparative data""" + + results = {} + + for ticker in tickers: + self.logger.info(f"Analyzing {ticker}...") + company_data = self.get_company_data(ticker, period) + if company_data: + results[ticker] = company_data + + return results + + def create_summary_report(self, analysis_results: Dict) -> pd.DataFrame: + """Create a summary report comparing multiple companies""" + + summary_data = [] + + for ticker, data in analysis_results.items(): + if not data: + continue + + analysis = data.get('analysis_summary', {}) + key_metrics = analysis.get('key_metrics', {}) + financial_health = analysis.get('financial_health', {}) + price_trends = analysis.get('price_trends', {}) + sentiment = analysis.get('sentiment_analysis', {}) + + row = { + 'Ticker': ticker, + 'Company': data.get('company_name', ''), + 'Investment Score': analysis.get('investment_score', 0), + 'Financial Grade': financial_health.get('grade', 'N/A'), + 'Price Trend': price_trends.get('trend', 'N/A'), + 'Sentiment': sentiment.get('sentiment_label', 'N/A') if sentiment else 'N/A', + # Financial metrics + 'Revenue (M)': key_metrics.get('revenue', 0) / 1_000_000 if key_metrics.get('revenue') else None, + 'Net Income (M)': key_metrics.get('net_income', 0) / 1_000_000 if key_metrics.get('net_income') else None, + 'EPS': key_metrics.get('eps'), + 'P/E Ratio': key_metrics.get('pe_ratio'), + 'ROE (%)': key_metrics.get('roe') * 100 if key_metrics.get('roe') else None, + 'Debt/Equity': key_metrics.get('debt_to_equity'), + # Price metrics + 'Current Price': price_trends.get('current_price'), + '30d Momentum (%)': price_trends.get('momentum_30d', 0) * 100 if price_trends.get('momentum_30d') else None, + 'Volatility (%)': price_trends.get('volatility', 0) * 100 if price_trends.get('volatility') else None, + # News metrics + 'News Articles': sentiment.get('news_articles_count', 0) if sentiment else 0, + 'Social Posts': sentiment.get('social_posts_count', 0) if sentiment else 0, + } + + summary_data.append(row) + + df = pd.DataFrame(summary_data) + + # Sort by investment score descending + if not df.empty and 'Investment Score' in df.columns: + df = df.sort_values('Investment Score', ascending=False) + + return df + + +# Example usage +if __name__ == "__main__": + # Initialize analyzer + analyzer = StockOracleAnalyzer("http://localhost:18001") + + # Analyze a single company + print("Analyzing Apple Inc. (AAPL)...") + aapl_data = analyzer.get_company_data("AAPL", period="2y") + + if aapl_data: + print(f"Successfully retrieved data for {aapl_data['company_name']}") + analysis = aapl_data['analysis_summary'] + + print(f"\n๐Ÿ“Š Investment Score: {analysis['investment_score']}/100") + print(f"๐Ÿ“ˆ Financial Grade: {analysis['financial_health']['grade']}") + print(f"๐Ÿ“‰ Price Trend: {analysis['price_trends']['trend']}") + if analysis['sentiment_analysis']: + print(f"๐Ÿ’ญ Sentiment: {analysis['sentiment_analysis']['sentiment_label']}") + + print(f"\n๐Ÿ’ฐ Key Metrics:") + metrics = analysis['key_metrics'] + if metrics.get('revenue'): + print(f" Revenue: ${metrics['revenue']/1e9:.2f}B") + if metrics.get('net_income'): + print(f" Net Income: ${metrics['net_income']/1e9:.2f}B") + if metrics.get('pe_ratio'): + print(f" P/E Ratio: {metrics['pe_ratio']:.2f}") + if metrics.get('roe'): + print(f" ROE: {metrics['roe']*100:.2f}%") + + # Analyze multiple companies + print("\n" + "="*80) + print("Analyzing multiple companies...") + + tickers = ["AAPL", "MSFT", "GOOGL", "TSLA", "NVDA"] + results = analyzer.analyze_multiple_companies(tickers, period="1y") + + # Create summary report + summary_df = analyzer.create_summary_report(results) + + if not summary_df.empty: + print("\n๐Ÿ“‹ Investment Summary Report:") + print("=" * 120) + + # Display key columns + display_cols = [ + 'Ticker', 'Company', 'Investment Score', 'Financial Grade', + 'Price Trend', 'Sentiment', 'P/E Ratio', 'ROE (%)', '30d Momentum (%)' + ] + + available_cols = [col for col in display_cols if col in summary_df.columns] + print(summary_df[available_cols].to_string(index=False)) + + # Top recommendation + if 'Investment Score' in summary_df.columns: + top_pick = summary_df.iloc[0] + print(f"\n๐Ÿ† Top Investment Recommendation: {top_pick['Ticker']} ({top_pick['Company']})") + print(f" Investment Score: {top_pick['Investment Score']}/100") + print(f" Financial Grade: {top_pick['Financial Grade']}") + else: + print("No data available for analysis.") \ No newline at end of file diff --git a/stock_oracle_client.py b/stock_oracle_client.py new file mode 100644 index 0000000..3e2337a --- /dev/null +++ b/stock_oracle_client.py @@ -0,0 +1,844 @@ +""" +Stock Oracle Python Client + +A comprehensive Python client library for accessing the Stock Oracle API. +Provides easy-to-use methods for retrieving financial, price, and ETF holdings data. + +Usage: + from stock_oracle_client import StockOracleClient + + client = StockOracleClient("http://localhost:18001") + + # Get financial data using period + data = client.get_financial_data("AAPL", period="1y") + + # Get price data using date range + prices = client.get_price_data("MSFT", start_date="2024-01-01", end_date="2024-12-31") + + # Get ETF holdings + etf = client.get_etf_holdings("QQQ", as_of_date="2024-01-01") +""" + +import requests +import json +from datetime import datetime, date, timedelta +from typing import Dict, List, Optional, Union, Any, Tuple +import logging +from enum import Enum + +logger = logging.getLogger(__name__) + + +class StockOracleError(Exception): + """Base exception for Stock Oracle client errors""" + pass + + +class StockOracleAPIError(StockOracleError): + """API-specific errors""" + def __init__(self, message: str, status_code: int = None, response_data: Dict = None): + super().__init__(message) + self.status_code = status_code + self.response_data = response_data + + +class ETFDataNotAvailableError(StockOracleError): + """ETF data not available for requested date""" + def __init__(self, message: str, availability_info: Dict = None): + super().__init__(message) + self.availability_info = availability_info + + +class PriceInterval(Enum): + """Valid price data intervals""" + ONE_MINUTE = "1m" + TWO_MINUTES = "2m" + FIVE_MINUTES = "5m" + FIFTEEN_MINUTES = "15m" + THIRTY_MINUTES = "30m" + SIXTY_MINUTES = "60m" + NINETY_MINUTES = "90m" + ONE_HOUR = "1h" + ONE_DAY = "1d" + FIVE_DAYS = "5d" + ONE_WEEK = "1wk" + ONE_MONTH = "1mo" + THREE_MONTHS = "3mo" + + +class PeriodType(Enum): + """Period types for financial data""" + QUARTERLY = "quarterly" + ANNUAL = "annual" + ALL = "all" + + +class StockOracleClient: + """ + Python client for Stock Oracle API + + Args: + base_url: Base URL of the Stock Oracle API (e.g., "http://localhost:18001") + api_key: Optional API key for authentication (not implemented yet) + timeout: Request timeout in seconds (default: 30) + auto_retry: Automatically retry failed requests (default: True) + max_retries: Maximum number of retries (default: 3) + """ + + def __init__( + self, + base_url: str, + api_key: Optional[str] = None, + timeout: int = 30, + auto_retry: bool = True, + max_retries: int = 3 + ): + self.base_url = base_url.rstrip('/') + self.api_key = api_key + self.timeout = timeout + self.auto_retry = auto_retry + self.max_retries = max_retries + self.session = requests.Session() + + # Set up session headers + self.session.headers.update({ + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'StockOracle-Python-Client/2.0.0' + }) + + if api_key: + self.session.headers['Authorization'] = f'Bearer {api_key}' + + def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict: + """ + Make HTTP request to API with retry logic + + Args: + method: HTTP method (GET, POST, etc.) + endpoint: API endpoint path + **kwargs: Additional arguments for requests + + Returns: + Parsed JSON response + + Raises: + StockOracleAPIError: On API errors + """ + url = f"{self.base_url}{endpoint}" + kwargs.setdefault('timeout', self.timeout) + + retries = 0 + while retries <= (self.max_retries if self.auto_retry else 0): + try: + response = self.session.request(method, url, **kwargs) + response.raise_for_status() + return response.json() + + except requests.exceptions.HTTPError as e: + try: + error_data = response.json() + except (ValueError, AttributeError): + error_data = {"message": response.text} + + # Don't retry on client errors (4xx) + if response.status_code < 500: + raise StockOracleAPIError( + f"API Error: {error_data.get('message', error_data.get('detail', str(e)))}", + status_code=response.status_code, + response_data=error_data + ) + + # Retry on server errors (5xx) + retries += 1 + if retries > self.max_retries: + raise StockOracleAPIError( + f"API Error after {self.max_retries} retries: {error_data.get('message', str(e))}", + status_code=response.status_code, + response_data=error_data + ) + + except requests.exceptions.RequestException as e: + retries += 1 + if retries > self.max_retries: + raise StockOracleAPIError(f"Request Error after {self.max_retries} retries: {str(e)}") + + def _format_date(self, date_obj: Union[str, date, datetime]) -> str: + """Format date object to API-compatible string""" + if isinstance(date_obj, str): + return date_obj + elif isinstance(date_obj, datetime): + return date_obj.date().isoformat() + elif isinstance(date_obj, date): + return date_obj.isoformat() + else: + raise ValueError(f"Invalid date type: {type(date_obj)}") + + # ============= Health & Status ============= + + def get_health(self) -> Dict: + """ + Get API health status + + Returns: + Health status information + """ + return self._make_request('GET', '/api/v1/health') + + def get_detailed_health(self) -> Dict: + """ + Get detailed health status including database and cache + + Returns: + Detailed health status + """ + return self._make_request('GET', '/api/v1/health/detailed') + + + # ============= Financial Data API ============= + + def get_financial_data( + self, + ticker: str, + start_date: Optional[Union[str, date, datetime]] = None, + end_date: Optional[Union[str, date, datetime]] = None, + quarters: Optional[List[str]] = None, + period: Optional[str] = None, + period_type: Union[str, PeriodType] = PeriodType.ALL, + include_metrics: bool = True, + force_refresh: bool = False + ) -> Dict: + """ + Get financial data for a stock ticker + + โš ๏ธ IMPORTANT: Period parameter now uses yesterday as end date to ensure data availability + + Args: + ticker: Stock ticker symbol (e.g., "AAPL") + start_date: Start date (string, date, or datetime) + end_date: End date (string, date, or datetime) + quarters: List of quarters (e.g., ["2024Q1", "2024Q2"]) + period: Period string (e.g., "1d", "3m", "2y") - automatically excludes today's data + period_type: Type of periods (PeriodType enum or string) + include_metrics: Include calculated metrics + force_refresh: Force refresh from SEC data + + Returns: + Financial data response + + Note: + Must specify exactly one of: (start_date + end_date), quarters, or period + """ + if isinstance(period_type, PeriodType): + period_type = period_type.value + + data = { + "ticker": ticker, + "period_type": period_type, + "include_metrics": include_metrics, + "force_refresh": force_refresh + } + + # Add time parameters + if period: + data["period"] = period + elif quarters: + data["quarters"] = quarters + elif start_date and end_date: + data["start_date"] = self._format_date(start_date) + data["end_date"] = self._format_date(end_date) + else: + # Default to last year + data["period"] = "1y" + + return self._make_request('POST', '/api/v1/financial/data', json=data) + + # ============= Price Data API ============= + + def get_price_data( + self, + ticker: str, + start_date: Optional[Union[str, date, datetime]] = None, + end_date: Optional[Union[str, date, datetime]] = None, + quarters: Optional[List[str]] = None, + period: Optional[str] = None, + interval: Union[str, PriceInterval] = PriceInterval.ONE_DAY, + force_refresh: bool = False + ) -> Dict: + """ + Get price data for a stock ticker + + โš ๏ธ IMPORTANT: Period parameter now uses yesterday as end date to ensure data availability + + Args: + ticker: Stock ticker symbol (e.g., "AAPL") + start_date: Start date (string, date, or datetime) + end_date: End date (string, date, or datetime) + quarters: List of quarters (e.g., ["2024Q1", "2024Q2"]) + period: Period string (e.g., "1d", "3m", "2y") - automatically excludes today's data + interval: Data interval (PriceInterval enum or string) + force_refresh: Force refresh from Yahoo Finance + + Returns: + Price data response + + Note: + Must specify exactly one of: (start_date + end_date), quarters, or period + """ + if isinstance(interval, PriceInterval): + interval = interval.value + + data = { + "ticker": ticker, + "interval": interval, + "force_refresh": force_refresh + } + + # Add time parameters + if period: + data["period"] = period + elif quarters: + data["quarters"] = quarters + elif start_date and end_date: + data["start_date"] = self._format_date(start_date) + data["end_date"] = self._format_date(end_date) + else: + # Default to last year + data["period"] = "1y" + + return self._make_request('POST', '/api/v1/price/data', json=data) + + # ============= Bulk Data API ============= + + def get_bulk_financial_data( + self, + tickers: List[str], + start_date: Optional[Union[str, date, datetime]] = None, + end_date: Optional[Union[str, date, datetime]] = None, + quarters: Optional[List[str]] = None, + period: Optional[str] = None, + period_type: Union[str, PeriodType] = PeriodType.ALL, + include_metrics: bool = True, + force_refresh: bool = False + ) -> Dict: + """ + Get financial data for multiple stock tickers + + โš ๏ธ IMPORTANT: Period parameter now uses yesterday as end date to ensure data availability + + Args: + tickers: List of stock ticker symbols (max 500) + start_date: Start date (string, date, or datetime) + end_date: End date (string, date, or datetime) + quarters: List of quarters (e.g., ["2024Q1", "2024Q2"]) + period: Period string (e.g., "1d", "3m", "2y") - automatically excludes today's data + period_type: Type of periods (PeriodType enum or string) + include_metrics: Include calculated metrics + force_refresh: Force refresh from SEC data + + Returns: + Bulk financial data response + + Note: + Must specify exactly one of: (start_date + end_date), quarters, or period + """ + if isinstance(period_type, PeriodType): + period_type = period_type.value + + data = { + "tickers": tickers, + "period_type": period_type, + "include_metrics": include_metrics, + "force_refresh": force_refresh + } + + # Add time parameters + if period: + data["period"] = period + elif quarters: + data["quarters"] = quarters + elif start_date and end_date: + data["start_date"] = self._format_date(start_date) + data["end_date"] = self._format_date(end_date) + else: + # Default to last year + data["period"] = "1y" + + return self._make_request('POST', '/api/v1/financial/data/bulk', json=data) + + def get_bulk_price_data( + self, + tickers: List[str], + start_date: Optional[Union[str, date, datetime]] = None, + end_date: Optional[Union[str, date, datetime]] = None, + quarters: Optional[List[str]] = None, + period: Optional[str] = None, + interval: Union[str, PriceInterval] = PriceInterval.ONE_DAY, + force_refresh: bool = False + ) -> Dict: + """ + Get price data for multiple stock tickers + + โš ๏ธ IMPORTANT: Period parameter now uses yesterday as end date to ensure data availability + + Args: + tickers: List of stock ticker symbols (max 500) + start_date: Start date (string, date, or datetime) + end_date: End date (string, date, or datetime) + quarters: List of quarters (e.g., ["2024Q1", "2024Q2"]) + period: Period string (e.g., "1d", "3m", "2y") - automatically excludes today's data + interval: Data interval (PriceInterval enum or string) + force_refresh: Force refresh from Yahoo Finance + + Returns: + Bulk price data response + + Note: + Must specify exactly one of: (start_date + end_date), quarters, or period + """ + if isinstance(interval, PriceInterval): + interval = interval.value + + data = { + "tickers": tickers, + "interval": interval, + "force_refresh": force_refresh + } + + # Add time parameters + if period: + data["period"] = period + elif quarters: + data["quarters"] = quarters + elif start_date and end_date: + data["start_date"] = self._format_date(start_date) + data["end_date"] = self._format_date(end_date) + else: + # Default to last year + data["period"] = "1y" + + return self._make_request('POST', '/api/v1/price/data/bulk', json=data) + + + # ============= News & Social Media API ============= + + def get_news_social_data( + self, + ticker: str, + days_back: int = 7, + max_articles: int = 20, + max_social_posts: int = 15, + include_social: bool = True + ) -> Dict: + """ + Get news and social media data for a stock ticker + + Args: + ticker: Stock ticker symbol (e.g., "AAPL") + days_back: Number of days to look back (default: 7) + max_articles: Maximum number of news articles (default: 20) + max_social_posts: Maximum number of social posts (default: 15) + include_social: Include social media data (default: True) + + Returns: + News and social media data response + """ + params = { + "days_back": days_back, + "max_articles": max_articles, + "max_social_posts": max_social_posts, + "include_social": str(include_social).lower() + } + + return self._make_request('GET', f'/api/v1/news/{ticker}', params=params) + + def get_news_only( + self, + ticker: str, + days_back: int = 7, + max_articles: int = 30 + ) -> Dict: + """ + Get news data only (faster response) + + Args: + ticker: Stock ticker symbol (e.g., "AAPL") + days_back: Number of days to look back (default: 7) + max_articles: Maximum number of news articles (default: 30) + + Returns: + News data response + """ + params = { + "days_back": days_back, + "max_articles": max_articles + } + + return self._make_request('GET', f'/api/v1/news/{ticker}/news-only', params=params) + + def get_social_only( + self, + ticker: str, + days_back: int = 7, + max_social_posts: int = 20 + ) -> Dict: + """ + Get social media data only + + Args: + ticker: Stock ticker symbol (e.g., "AAPL") + days_back: Number of days to look back (default: 7) + max_social_posts: Maximum number of social posts (default: 20) + + Returns: + Social media data response + """ + params = { + "days_back": days_back, + "max_social_posts": max_social_posts + } + + return self._make_request('GET', f'/api/v1/news/{ticker}/social-only', params=params) + + # ============= Metadata & Catalog ============= + + def get_data_catalog(self) -> Dict: + """ + Get data field catalog + + Returns: + Data catalog with field descriptions + """ + return self._make_request('GET', '/api/v1/metadata/catalog') + + def get_error_logs( + self, + limit: int = 100, + offset: int = 0, + min_level: str = "ERROR" + ) -> Dict: + """ + Get error logs (admin) + + Args: + limit: Number of logs to retrieve + offset: Pagination offset + min_level: Minimum log level (ERROR, WARNING, INFO) + + Returns: + Error logs + """ + params = { + "limit": limit, + "offset": offset, + "min_level": min_level + } + return self._make_request('GET', '/api/v1/admin/errors', params=params) + + # ============= Migration & Admin ============= + + def migrate_data( + self, + source_url: str, + api_key: str, + tickers: Optional[List[str]] = None, + start_date: Optional[Union[str, date, datetime]] = None, + end_date: Optional[Union[str, date, datetime]] = None + ) -> Dict: + """ + Migrate data from another Stock Oracle instance + + Args: + source_url: Source API URL + api_key: API key for migration + tickers: Specific tickers to migrate (optional) + start_date: Start date for migration (optional) + end_date: End date for migration (optional) + + Returns: + Migration status + """ + data = { + "source_url": source_url, + "api_key": api_key + } + + if tickers: + data["tickers"] = tickers + if start_date: + data["start_date"] = self._format_date(start_date) + if end_date: + data["end_date"] = self._format_date(end_date) + + # Server expects X-API-Key header (dependency verify_migration_key) + headers = {"X-API-Key": api_key} + return self._make_request('POST', '/api/v1/admin/migrate', json=data, headers=headers) + + # ============= Utility Methods ============= + + def search_tickers(self, query: str) -> List[str]: + """ + Search for ticker symbols (client-side) + + Args: + query: Search query + + Returns: + List of matching ticker symbols + """ + # This is a placeholder - you might want to implement a real search + # against a ticker database or API endpoint + common_tickers = [ + "AAPL", "MSFT", "GOOGL", "AMZN", "META", "TSLA", "NVDA", + "QQQ", "SPY", "IWM", "EFA", "EEM", "VTI", "VOO", "ARKK" + ] + query = query.upper() + return [t for t in common_tickers if query in t] + + def validate_ticker(self, ticker: str) -> bool: + """ + Validate if a ticker exists + + Args: + ticker: Ticker symbol to validate + + Returns: + True if ticker is valid + """ + try: + # Try to get minimal data to validate ticker + response = self.get_financial_data( + ticker, + period="1d", + include_metrics=False + ) + return 'error' not in response + except: + return False + + def get_latest_filing_date(self, ticker: str) -> Optional[str]: + """ + Get the latest SEC filing date for a ticker + + Args: + ticker: Stock ticker symbol + + Returns: + Latest filing date as string or None + """ + try: + data = self.get_financial_data(ticker, period="1d") + if data.get('financial_data'): + return data['financial_data'][0].get('date') + except: + pass + return None + + +# Convenience functions for quick access +def get_financial_data(ticker: str, period: str = "1y", base_url: str = "http://localhost:18001") -> Dict: + """ + Quick function to get financial data + + Args: + ticker: Stock ticker symbol + period: Period string (e.g., "1d", "3m", "2y") + base_url: API base URL + + Returns: + Financial data + """ + client = StockOracleClient(base_url) + return client.get_financial_data(ticker, period=period) + + +def get_price_data(ticker: str, period: str = "1y", base_url: str = "http://localhost:18001") -> Dict: + """ + Quick function to get price data + + Args: + ticker: Stock ticker symbol + period: Period string (e.g., "1d", "3m", "2y") + base_url: API base URL + + Returns: + Price data + """ + client = StockOracleClient(base_url) + return client.get_price_data(ticker, period=period) + + +def get_etf_holdings(ticker: str, as_of_date: Optional[str] = None, base_url: str = "http://localhost:18001") -> Dict: + """ + Quick function to get ETF holdings + + Args: + ticker: ETF ticker symbol + as_of_date: Optional date for historical data + base_url: API base URL + + Returns: + ETF holdings data + """ + client = StockOracleClient(base_url) + return client.get_etf_holdings(ticker, as_of_date=as_of_date) + + +def get_news_social_data( + ticker: str, + days_back: int = 7, + max_articles: int = 20, + include_social: bool = True, + base_url: str = "http://localhost:18001" +) -> Dict: + """ + Quick function to get news and social media data + + Args: + ticker: Stock ticker symbol + days_back: Number of days to look back + max_articles: Maximum number of news articles + include_social: Include social media data + base_url: API base URL + + Returns: + News and social media data + """ + client = StockOracleClient(base_url) + return client.get_news_social_data(ticker, days_back, max_articles, include_social=include_social) + + +if __name__ == "__main__": + # Example usage + client = StockOracleClient("http://localhost:18001") + + try: + # Test health + health = client.get_health() + print("API Health:", health["status"]) + + print("\n" + "="*50) + print("FINANCIAL DATA EXAMPLES") + print("="*50) + + # Get financial data using period (POST endpoint) + print("\n=== Financial Data (Period: 1y) ===") + financial = client.get_financial_data("AAPL", period="1y") + print(f"Company: {financial['company']['name']}") + 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: + print(f" P/E Ratio: {latest['metrics'].get('pe_ratio', 'N/A')}") + print(f" ROE: {latest['metrics'].get('return_on_equity', 'N/A')}") + + print("\n" + "="*50) + print("PRICE DATA EXAMPLES") + print("="*50) + + # Get price data using date range + print("\n=== Price Data (Date Range) ===") + from datetime import date + price = client.get_price_data( + "AAPL", + start_date=date(2024, 1, 1), + end_date=date(2024, 12, 31), + interval=PriceInterval.ONE_DAY + ) + print(f"Ticker: {price['ticker']}") + print(f"Price points: {len(price['price_data'])}") + if price['price_data']: + latest = price['price_data'][-1] + print(f"Latest date: {latest['date']}") + print(f" Close: ${latest['close']:.2f}") + print(f" Volume: {latest['volume']:,}") + + print("\n" + "="*50) + print("BULK DATA EXAMPLES") + print("="*50) + + # Bulk financial data + print("\n=== Bulk Financial Data ===") + bulk_financial = client.get_bulk_financial_data( + ["AAPL", "MSFT", "GOOGL"], + period="3m", + period_type=PeriodType.QUARTERLY + ) + print(f"Requested: {len(bulk_financial['requested_tickers'])} tickers") + print(f"Successful: {len(bulk_financial['results'])} tickers") + if bulk_financial.get('errors'): + print(f"Failed: {len(bulk_financial['errors'])} tickers") + + + print("\n" + "="*50) + print("NEWS & SOCIAL MEDIA EXAMPLES") + print("="*50) + + # Get news and social media data + print("\n=== News & Social Media Data ===") + news_social = client.get_news_social_data("AAPL", days_back=7, max_articles=10, include_social=True) + print(f"Ticker: {news_social['ticker']}") + print(f"Retrieved at: {news_social['retrieved_at']}") + print(f"Total news articles: {news_social['news']['total_articles']}") + print(f"Total social posts: {news_social['social_media']['total_posts']}") + print(f"Total items: {news_social['summary']['total_items']}") + + if news_social['news']['articles']: + print("\nLatest news article:") + article = news_social['news']['articles'][0] + print(f" Title: {article['title'][:80]}...") + print(f" Source: {article['source']}") + print(f" Published: {article.get('published_at', 'N/A')}") + + if news_social['social_media']['posts']: + print("\nLatest social post:") + post = news_social['social_media']['posts'][0] + print(f" Title: {post['title'][:80]}...") + print(f" Platform: {post['platform']}") + print(f" Score: {post.get('score', 'N/A')}") + + # Get news only (faster) + print("\n=== News Only (Faster) ===") + news_only = client.get_news_only("TSLA", days_back=3, max_articles=5) + print(f"News articles for TSLA: {news_only['news']['total_articles']}") + + # Get social media only + print("\n=== Social Media Only ===") + social_only = client.get_social_only("NVDA", days_back=5, max_social_posts=10) + print(f"Social posts for NVDA: {social_only['social_media']['total_posts']}") + + print("\n" + "="*50) + print("ADDITIONAL FEATURES") + print("="*50) + + # Get supported ETFs + print("\n=== Supported ETFs ===") + supported = client.get_supported_etfs() + print(f"Total supported ETFs: {supported['total_etfs']}") + print(f"Examples: {', '.join(supported['supported_tickers'][:10])}...") + + # Search tickers (client-side example) + print("\n=== Ticker Search ===") + results = client.search_tickers("AA") + print(f"Search 'AA' results: {results}") + + # Validate ticker + print("\n=== Ticker Validation ===") + is_valid = client.validate_ticker("AAPL") + print(f"AAPL is valid: {is_valid}") + is_valid = client.validate_ticker("INVALID123") + print(f"INVALID123 is valid: {is_valid}") + + except StockOracleAPIError as e: + print(f"\nAPI Error: {e}") + print(f"Status Code: {e.status_code}") + print(f"Response: {e.response_data}") + except ETFDataNotAvailableError as e: + print(f"\nETF Data Not Available: {e}") + if e.availability_info: + print(f"Availability Info: {json.dumps(e.availability_info, indent=2)}") + except Exception as e: + print(f"\nError: {e}") + import traceback + traceback.print_exc() \ No newline at end of file diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..c8de929 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,173 @@ +# Stock Oracle ํ…Œ์ŠคํŠธ ์Šค์œ„ํŠธ + +์ด ํด๋”์—๋Š” Stock Oracle ์‹œ์Šคํ…œ์˜ ๋‹ค์–‘ํ•œ ๊ธฐ๋Šฅ์„ ํ…Œ์ŠคํŠธํ•˜๋Š” ์Šคํฌ๋ฆฝํŠธ๋“ค์ด ํฌํ•จ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. + +## ๐Ÿ“ ํ…Œ์ŠคํŠธ ํŒŒ์ผ ๋ชฉ๋ก + +### 1. `test_yfinance_fallback.py` +**๋ชฉ์ **: YFinance Plus ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ +- yfinance_plus ๋ชจ๋“ˆ์ด ์ •์ƒ์ ์œผ๋กœ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธ +- ๊ธฐ๋ณธ์ ์ธ ์ฃผ์‹ ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ ํ…Œ์ŠคํŠธ +- API ์ˆ˜๋™ ํ˜ธ์ถœ ํ…Œ์ŠคํŠธ + +**์‹คํ–‰ ๋ฐฉ๋ฒ•**: +```bash +python tests/test_yfinance_fallback.py +``` + +### 2. `test_simple_real_data.py` +**๋ชฉ์ **: ์‹ค์ œ SEC ๋ฐ์ดํ„ฐ ๊ธฐ๋ณธ ํ…Œ์ŠคํŠธ +- Stock Oracle API์—์„œ ์‹ค์ œ SEC ์žฌ๋ฌด ๋ฐ์ดํ„ฐ๋ฅผ ์ •์ƒ์ ์œผ๋กœ ๊ฐ€์ ธ์˜ค๋Š”์ง€ ํ™•์ธ +- ๋ฐ์ดํ„ฐ ํ’ˆ์งˆ ๊ฒ€์ฆ (์‹ค์ œ ๋ฐ์ดํ„ฐ vs ์ถ”์ • ๋ฐ์ดํ„ฐ) +- YFinance Plus์™€์˜ ๊ธฐ๋ณธ ๋น„๊ต + +**์‹คํ–‰ ๋ฐฉ๋ฒ•**: +```bash +python tests/test_simple_real_data.py +``` + +### 3. `test_real_sec_vs_yfinance_plus.py` +**๋ชฉ์ **: Stock Oracle vs YFinance Plus ์ƒ์„ธ ๋น„๊ต +- ๋‘ ์‹œ์Šคํ…œ์—์„œ ๊ฐ€์ ธ์˜จ ์žฌ๋ฌด ๋ฐ์ดํ„ฐ์˜ ์ •ํ™•๋„ ๋น„๊ต +- ์ฃผ์š” ์žฌ๋ฌด ์ง€ํ‘œ๋“ค์˜ ์ฐจ์ด์  ๋ถ„์„ +- ์—ฌ๋Ÿฌ ์ข…๋ชฉ(AAPL, MSFT)์— ๋Œ€ํ•œ ๋น„๊ต ํ…Œ์ŠคํŠธ + +**์‹คํ–‰ ๋ฐฉ๋ฒ•**: +```bash +python tests/test_real_sec_vs_yfinance_plus.py +``` + +### 4. `test_5year_comparison.py` +**๋ชฉ์ **: 5๋…„ ๋ถ„๊ธฐ๋ณ„ ์ •ํ™•๋„ ํ…Œ์ŠคํŠธ +- ๋™์ผํ•œ ๋ถ„๊ธฐ ๋ฐ์ดํ„ฐ์˜ ์ •ํ™•๋„๋ฅผ 5๋…„๊ฐ„ ๋น„๊ต ๊ฒ€์ฆ +- ๋ถ„๊ธฐ๋ณ„ ๋งค์นญ๋œ ๋ฐ์ดํ„ฐ๋งŒ ๋น„๊ตํ•˜์—ฌ ์ •ํ™•๋„ ์ธก์ • +- ์‹œ๊ณ„์—ด ๋ฐ์ดํ„ฐ์˜ ์ผ๊ด€์„ฑ ๊ฒ€์ฆ + +**์‹คํ–‰ ๋ฐฉ๋ฒ•**: +```bash +python tests/test_5year_comparison.py +``` + +### 5. `run_all_tests.py` (ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ์Šค์œ„ํŠธ) +**๋ชฉ์ **: ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋ฅผ ํ•œ๋ฒˆ์— ์‹คํ–‰ +- ์œ„์˜ ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋ฅผ ์ˆœ์ฐจ์ ์œผ๋กœ ์‹คํ–‰ +- ๊ฐ ํ…Œ์ŠคํŠธ์˜ ์„ฑ๊ณต/์‹คํŒจ ๊ฒฐ๊ณผ ์š”์•ฝ +- ์‹คํ–‰ ์‹œ๊ฐ„ ์ธก์ • ๋ฐ ๋ฆฌํฌํŠธ ์ƒ์„ฑ + +**์‹คํ–‰ ๋ฐฉ๋ฒ•**: +```bash +python tests/run_all_tests.py +``` + +## ๐Ÿš€ ๋น ๋ฅธ ์‹œ์ž‘ + +### ์ „์ฒด ํ…Œ์ŠคํŠธ ์‹คํ–‰ +```bash +# ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋ฅผ ํ•œ๋ฒˆ์— ์‹คํ–‰ +python tests/run_all_tests.py +``` + +### ๊ฐœ๋ณ„ ํ…Œ์ŠคํŠธ ์‹คํ–‰ +```bash +# ๊ธฐ๋ณธ์ ์ธ ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ๋งŒ +python tests/test_yfinance_fallback.py + +# ์‹ค์ œ ๋ฐ์ดํ„ฐ ํ…Œ์ŠคํŠธ๋งŒ +python tests/test_simple_real_data.py + +# ์ •ํ™•๋„ ๋น„๊ต ํ…Œ์ŠคํŠธ๋งŒ +python tests/test_real_sec_vs_yfinance_plus.py + +# 5๋…„ ์ •ํ™•๋„ ๊ฒ€์ฆ๋งŒ +python tests/test_5year_comparison.py +``` + +## ๐Ÿ“‹ ์‚ฌ์ „ ์š”๊ตฌ์‚ฌํ•ญ + +ํ…Œ์ŠคํŠธ ์‹คํ–‰ ์ „์— ๋‹ค์Œ ์กฐ๊ฑด๋“ค์ด ๋งŒ์กฑ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค: + +### 1. Docker ์„œ๋น„์Šค ์‹คํ–‰ +```bash +docker-compose up -d +``` + +### 2. ํ•„์ˆ˜ Python ํŒจํ‚ค์ง€ +- `requests` +- `yfinance_plus` +- `pandas` +- `json` + +### 3. API ์„œ๋ฒ„ ํ™•์ธ +Stock Oracle API๊ฐ€ `http://localhost:18001`์—์„œ ์‹คํ–‰ ์ค‘์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + +```bash +# ์„œ๋ฒ„ ์ƒํƒœ ํ™•์ธ +curl http://localhost:18001/health +``` + +## ๐Ÿ“Š ์˜ˆ์ƒ ๊ฒฐ๊ณผ + +### ์„ฑ๊ณต์ ์ธ ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ +- **YFinance Plus ์—ฐ๊ฒฐ**: โœ… ์ •์ƒ ์—ฐ๊ฒฐ ๋ฐ ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ +- **์‹ค์ œ SEC ๋ฐ์ดํ„ฐ**: โœ… ์‹ค์ œ ๋ฐ์ดํ„ฐ (is_estimated: false) ๋ฐ˜ํ™˜ +- **๋ฐ์ดํ„ฐ ๋น„๊ต**: โœ… ํ•ฉ๋ฆฌ์ ์ธ ์ฐจ์ด ๋ฒ”์œ„ ๋‚ด (๋™์ผ ๋ถ„๊ธฐ ๋Œ€๋น„) +- **5๋…„ ์ •ํ™•๋„**: โœ… ๋™์ผ ๋ถ„๊ธฐ ๋ฐ์ดํ„ฐ 100% ์ผ์น˜ + +### ๋ฌธ์ œ ํ•ด๊ฒฐ + +#### API ์„œ๋ฒ„ ์—ฐ๊ฒฐ ์‹คํŒจ +```bash +# Docker ์ปจํ…Œ์ด๋„ˆ ์ƒํƒœ ํ™•์ธ +docker-compose ps + +# ์„œ๋น„์Šค ์žฌ์‹œ์ž‘ +docker-compose restart +``` + +#### yfinance_plus ๋ชจ๋“ˆ ์˜ค๋ฅ˜ +```bash +# ๋ชจ๋“ˆ ์žฌ์„ค์น˜ +pip install yfinance_plus + +# Docker ๋‚ด๋ถ€์—์„œ ์žฌ์„ค์น˜ +docker-compose exec api pip install yfinance_plus +``` + +#### ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ์˜ค๋ฅ˜ +```bash +# PostgreSQL ์ปจํ…Œ์ด๋„ˆ ์žฌ์‹œ์ž‘ +docker-compose restart postgres + +# ๋กœ๊ทธ ํ™•์ธ +docker-compose logs postgres +``` + +## ๐ŸŽฏ ํ…Œ์ŠคํŠธ ๋ชฉํ‘œ + +์ด ํ…Œ์ŠคํŠธ ์Šค์œ„ํŠธ์˜ ๋ชฉํ‘œ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค: + +1. **๊ธฐ๋Šฅ ๊ฒ€์ฆ**: ๋ชจ๋“  ํ•ต์‹ฌ ๊ธฐ๋Šฅ์ด ์ •์ƒ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธ +2. **๋ฐ์ดํ„ฐ ์ •ํ™•์„ฑ**: ์‹ค์ œ SEC ๋ฐ์ดํ„ฐ๊ฐ€ ์ •ํ™•ํ•˜๊ฒŒ ๊ฐ€์ ธ์™€์ง€๋Š”์ง€ ๊ฒ€์ฆ +3. **์ผ๊ด€์„ฑ ํ™•์ธ**: ์‹œ์Šคํ…œ ๊ฐ„ ๋ฐ์ดํ„ฐ ์ผ๊ด€์„ฑ ๋ณด์žฅ +4. **์„ฑ๋Šฅ ์ธก์ •**: ๊ฐ ํ…Œ์ŠคํŠธ์˜ ์‹คํ–‰ ์‹œ๊ฐ„ ์ธก์ • +5. **ํšŒ๊ท€ ๋ฐฉ์ง€**: ํ–ฅํ›„ ์ฝ”๋“œ ๋ณ€๊ฒฝ ์‹œ ๊ธฐ์กด ๊ธฐ๋Šฅ ๋ณดํ˜ธ + +## ๐Ÿ“ ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ ํ•ด์„ + +### ์„ฑ๊ณต ์ง€ํ‘œ +- ๋ชจ๋“  API ํ˜ธ์ถœ์ด 200 ์ƒํƒœ ์ฝ”๋“œ ๋ฐ˜ํ™˜ +- ์‹ค์ œ ์žฌ๋ฌด ๋ฐ์ดํ„ฐ (`is_estimated: false`) ๋ฐ˜ํ™˜ +- ๋™์ผ ๋ถ„๊ธฐ ๋ฐ์ดํ„ฐ์˜ ์ •ํ™•ํ•œ ์ผ์น˜ +- ํ•ฉ๋ฆฌ์ ์ธ ์‹คํ–‰ ์‹œ๊ฐ„ (๊ฐ ํ…Œ์ŠคํŠธ < 5๋ถ„) + +### ์ฃผ์˜์‚ฌํ•ญ +- ์„œ๋กœ ๋‹ค๋ฅธ ๋ถ„๊ธฐ ๋ฐ์ดํ„ฐ ๋น„๊ต ์‹œ ์ฐจ์ด๋Š” ์ •์ƒ +- ๋„คํŠธ์›Œํฌ ์ƒํƒœ์— ๋”ฐ๋ผ ์‹คํ–‰ ์‹œ๊ฐ„ ๋ณ€๋™ ๊ฐ€๋Šฅ +- ์ผ๋ถ€ ์ข…๋ชฉ์˜ ๊ฒฝ์šฐ ํŠน์ • ๋ถ„๊ธฐ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์„ ์ˆ˜ ์žˆ์Œ + +## ๐Ÿ”„ ์ •๊ธฐ ํ…Œ์ŠคํŠธ ๊ถŒ์žฅ + +- **๊ฐœ๋ฐœ ํ›„**: ์ฝ”๋“œ ๋ณ€๊ฒฝ ํ›„ ์ „์ฒด ํ…Œ์ŠคํŠธ ์‹คํ–‰ +- **๋ฐฐํฌ ์ „**: ํ”„๋กœ๋•์…˜ ๋ฐฐํฌ ์ „ ํ•„์ˆ˜ ๊ฒ€์ฆ +- **์ฃผ๊ธฐ์ **: ์ฃผ 1ํšŒ ๋ฐ์ดํ„ฐ ์ •ํ™•์„ฑ ๊ฒ€์ฆ +- **๋ฌธ์ œ ๋ฐœ์ƒ ์‹œ**: ์ฆ‰์‹œ ํ•ด๋‹น ์˜์—ญ ํ…Œ์ŠคํŠธ ์‹คํ–‰ \ No newline at end of file diff --git a/tests/TEST_SUMMARY.md b/tests/TEST_SUMMARY.md new file mode 100644 index 0000000..2839797 --- /dev/null +++ b/tests/TEST_SUMMARY.md @@ -0,0 +1,157 @@ +# ๐Ÿ“Š Stock Oracle ํ…Œ์ŠคํŠธ ์Šค์œ„ํŠธ ์™„๋ฃŒ ๋ณด๊ณ ์„œ + +## ๐ŸŽฏ ํ…Œ์ŠคํŠธ ๊ตฌ์„ฑ ์™„๋ฃŒ + +Stock Oracle ์‹œ์Šคํ…œ์˜ ๋ชจ๋“  ์ค‘์š” ๊ธฐ๋Šฅ์— ๋Œ€ํ•œ ์ข…ํ•ฉ์ ์ธ ํ…Œ์ŠคํŠธ ์Šค์œ„ํŠธ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ๊ตฌ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. + +### ๐Ÿ“ ๊ตฌ์„ฑ๋œ ํ…Œ์ŠคํŠธ ํŒŒ์ผ๋“ค + +#### 1๏ธโƒฃ **ํ•ต์‹ฌ ๊ธฐ๋Šฅ ํ…Œ์ŠคํŠธ** +- **`test_yfinance_fallback.py`** - YFinance Plus ๋ชจ๋“ˆ ์—ฐ๊ฒฐ ๋ฐ ๊ธฐ๋ณธ ๊ธฐ๋Šฅ ๊ฒ€์ฆ +- **`test_simple_real_data.py`** - ์‹ค์ œ SEC ์žฌ๋ฌด ๋ฐ์ดํ„ฐ ๊ธฐ๋ณธ ๊ฐ€์ ธ์˜ค๊ธฐ ํ…Œ์ŠคํŠธ +- **`test_real_sec_vs_yfinance_plus.py`** - Stock Oracle vs YFinance Plus ์ •ํ™•๋„ ๋น„๊ต +- **`test_5year_comparison.py`** - 5๋…„๊ฐ„ ๋ถ„๊ธฐ๋ณ„ ๋ฐ์ดํ„ฐ ์ •ํ™•๋„ ๊ฒ€์ฆ + +#### 2๏ธโƒฃ **ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ๋„๊ตฌ** +- **`run_all_tests.py`** - ๋ชจ๋“  ํ…Œ์ŠคํŠธ ์ž๋™ ์‹คํ–‰ ๋ฐ ๊ฒฐ๊ณผ ์š”์•ฝ +- **`test_quick.py`** - ๋น ๋ฅธ ๊ธฐ๋Šฅ ํ™•์ธ ํ…Œ์ŠคํŠธ (๊ฐœ๋ฐœ ์ค‘) +- **`run_tests.sh`** - ์‰˜ ์Šคํฌ๋ฆฝํŠธ ์‹คํ–‰๊ธฐ + +#### 3๏ธโƒฃ **๋ฌธ์„œํ™”** +- **`README.md`** - ํ…Œ์ŠคํŠธ ์‚ฌ์šฉ๋ฒ• ๋ฐ ์„ค๋ช…์„œ +- **`TEST_SUMMARY.md`** - ์ด ๋ณด๊ณ ์„œ + +## โœ… ํ…Œ์ŠคํŠธ ์‹คํ–‰ ๊ฒฐ๊ณผ + +### ๐Ÿ† ์™„๋ฒฝํ•œ ์„ฑ๊ณต๋ฅ  ๋‹ฌ์„ฑ +``` +================================================================================ +๐Ÿ“Š ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ ์š”์•ฝ (2025-08-02 12:05:49) +================================================================================ +์ด ํ…Œ์ŠคํŠธ: 4๊ฐœ +์„ฑ๊ณต: 4๊ฐœ โœ… +์‹คํŒจ: 0๊ฐœ โŒ +์„ฑ๊ณต๋ฅ : 100.0% +์ด ์‹คํ–‰์‹œ๊ฐ„: 22.3์ดˆ +``` + +### ๐Ÿ“ˆ ๊ฐœ๋ณ„ ํ…Œ์ŠคํŠธ ์„ฑ๊ณผ + +| ํ…Œ์ŠคํŠธ | ์‹คํ–‰์‹œ๊ฐ„ | ๊ฒฐ๊ณผ | ํ•ต์‹ฌ ๊ฒ€์ฆ ์‚ฌํ•ญ | +|--------|----------|------|----------------| +| YFinance Plus ์—ฐ๊ฒฐ | 2.5์ดˆ | โœ… ์„ฑ๊ณต | ์™ธ๋ถ€ ๋ฐ์ดํ„ฐ ์†Œ์Šค ์—ฐ๊ฒฐ | +| ์‹ค์ œ SEC ๋ฐ์ดํ„ฐ | 2.6์ดˆ | โœ… ์„ฑ๊ณต | ์‹ค์ œ ์žฌ๋ฌด ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ | +| Oracle vs YFinance ๋น„๊ต | 10.6์ดˆ | โœ… ์„ฑ๊ณต | ๋ฐ์ดํ„ฐ ์ •ํ™•์„ฑ ๊ฒ€์ฆ | +| 5๋…„ ๋ถ„๊ธฐ๋ณ„ ์ •ํ™•๋„ | 6.6์ดˆ | โœ… ์„ฑ๊ณต | ์žฅ๊ธฐ๊ฐ„ ๋ฐ์ดํ„ฐ ์ผ๊ด€์„ฑ | + +## ๐Ÿ” ํ•ต์‹ฌ ๊ฒ€์ฆ ๊ฒฐ๊ณผ + +### ๐Ÿ’ฏ ์™„๋ฒฝํ•œ ๋ฐ์ดํ„ฐ ์ •ํ™•๋„ +**๋™์ผ ๋ถ„๊ธฐ ๋น„๊ต ๊ฒฐ๊ณผ:** +- **AAPL (Apple)**: 4๊ฐœ ๋ถ„๊ธฐ ๋ชจ๋‘ **0.00% ์ฐจ์ด** +- **MSFT (Microsoft)**: 3๊ฐœ ๋ถ„๊ธฐ ๋ชจ๋‘ **0.00% ์ฐจ์ด** + +### ๐Ÿ“Š ๊ฒ€์ฆ๋œ ์ง€ํ‘œ๋“ค +โœ… **๋งค์ถœ (Revenue)**: ์™„๋ฒฝ ์ผ์น˜ +โœ… **์ˆœ์ด์ต (Net Income)**: ์™„๋ฒฝ ์ผ์น˜ +โœ… **์ฃผ๋‹น์ˆœ์ด์ต (EPS)**: ์™„๋ฒฝ ์ผ์น˜ +โœ… **๋ฐœํ–‰์ฃผ์‹์ˆ˜ (Shares Outstanding)**: ์™„๋ฒฝ ์ผ์น˜ +โœ… **๋ฐ์ดํ„ฐ ์†Œ์Šค**: `SEC_EDGAR` ํ™•์ธ +โœ… **์‹ค์ œ ๋ฐ์ดํ„ฐ**: `is_estimated: false` ํ™•์ธ + +## ๐Ÿš€ ์‚ฌ์šฉ ๋ฐฉ๋ฒ• + +### ๐ŸŽช ๋ชจ๋“  ํ…Œ์ŠคํŠธ ํ•œ๋ฒˆ์— ์‹คํ–‰ +```bash +# Python์œผ๋กœ ์‹คํ–‰ +python tests/run_all_tests.py + +# ๋˜๋Š” ์‰˜ ์Šคํฌ๋ฆฝํŠธ๋กœ ์‹คํ–‰ +./run_tests.sh +``` + +### ๐Ÿ”ง ๊ฐœ๋ณ„ ํ…Œ์ŠคํŠธ ์‹คํ–‰ +```bash +# ๊ธฐ๋ณธ ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ +python tests/test_yfinance_fallback.py + +# ์‹ค์ œ ๋ฐ์ดํ„ฐ ํ…Œ์ŠคํŠธ +python tests/test_simple_real_data.py + +# ์ •ํ™•๋„ ๋น„๊ต ํ…Œ์ŠคํŠธ +python tests/test_real_sec_vs_yfinance_plus.py + +# 5๋…„ ์ •ํ™•๋„ ๊ฒ€์ฆ +python tests/test_5year_comparison.py + +# ๋น ๋ฅธ ๊ธฐ๋Šฅ ํ™•์ธ (๊ฐœ๋ฐœ ์ค‘) +python tests/test_quick.py +``` + +## ๐Ÿ“‹ ์‚ฌ์ „ ์š”๊ตฌ์‚ฌํ•ญ + +### โœ… ํ•„์ˆ˜ ์กฐ๊ฑด๋“ค +1. **Docker ์„œ๋น„์Šค ์‹คํ–‰**: `docker-compose up -d` +2. **API ์„œ๋ฒ„ ๊ฐ€๋™**: `http://localhost:18001` ์ ‘๊ทผ ๊ฐ€๋Šฅ +3. **Python ํŒจํ‚ค์ง€**: `requests`, `yfinance_plus`, `pandas` ์„ค์น˜ +4. **PostgreSQL ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค**: ํฌํŠธ 15433์—์„œ ์‹คํ–‰ ์ค‘ + +### ๐Ÿ” ์„œ๋ฒ„ ์ƒํƒœ ํ™•์ธ +```bash +# API ๋ฌธ์„œ ํŽ˜์ด์ง€ ์ ‘๊ทผ ํ™•์ธ +curl http://localhost:18001/docs + +# Docker ์ปจํ…Œ์ด๋„ˆ ์ƒํƒœ ํ™•์ธ +docker-compose ps +``` + +## ๐ŸŽ‰ ๋‹ฌ์„ฑ๋œ ๋ชฉํ‘œ + +### 1๏ธโƒฃ **์™„์ „ํ•œ ์ž๋™ํ™”** +- ์›ํด๋ฆญ์œผ๋กœ ๋ชจ๋“  ํ…Œ์ŠคํŠธ ์‹คํ–‰ ๊ฐ€๋Šฅ +- ์ž๋™ํ™”๋œ ๊ฒฐ๊ณผ ์š”์•ฝ ๋ฐ ๋ฆฌํฌํŒ… +- ์‹คํŒจ ์‹œ ์ƒ์„ธํ•œ ์—๋Ÿฌ ์ •๋ณด ์ œ๊ณต + +### 2๏ธโƒฃ **ํฌ๊ด„์ ์ธ ๊ฒ€์ฆ** +- ์™ธ๋ถ€ ๋ฐ์ดํ„ฐ ์†Œ์Šค ์—ฐ๊ฒฐ ํ™•์ธ +- ์‹ค์ œ SEC ์žฌ๋ฌด ๋ฐ์ดํ„ฐ ์ •ํ™•์„ฑ ๊ฒ€์ฆ +- ์žฅ๊ธฐ๊ฐ„ ๋ฐ์ดํ„ฐ ์ผ๊ด€์„ฑ ํ™•์ธ +- ๊ณ„์‚ฐ๋œ ์ง€ํ‘œ๋“ค์˜ ์ •ํ™•๋„ ๊ฒ€์ฆ + +### 3๏ธโƒฃ **ํ’ˆ์งˆ ๋ณด์ฆ** +- 100% ํ…Œ์ŠคํŠธ ์„ฑ๊ณต๋ฅ  ๋‹ฌ์„ฑ +- ์‹ค์ œ ๋ฐ์ดํ„ฐ์™€ yfinance_plus ๊ฐ„ ์™„๋ฒฝํ•œ ์ผ์น˜ ํ™•์ธ +- ๋ชจ๋“  ์ฃผ์š” ์žฌ๋ฌด ์ง€ํ‘œ ๊ฒ€์ฆ ์™„๋ฃŒ + +### 4๏ธโƒฃ **๊ฐœ๋ฐœ์ž ์นœํ™”์ ** +- ๋ช…ํ™•ํ•œ ๋ฌธ์„œํ™” ๋ฐ ์‚ฌ์šฉ๋ฒ• ์ œ๊ณต +- ๋‹จ๊ณ„๋ณ„ ํ…Œ์ŠคํŠธ ์‹คํ–‰ ๊ฐ€๋Šฅ +- ์‹คํ–‰ ์‹œ๊ฐ„ ๋ฐ ์„ฑ๋Šฅ ์ง€ํ‘œ ์ œ๊ณต + +## ๐Ÿ”„ ํ–ฅํ›„ ์œ ์ง€๋ณด์ˆ˜ + +### ๐Ÿ“… ๊ถŒ์žฅ ํ…Œ์ŠคํŠธ ์ผ์ • +- **๊ฐœ๋ฐœ ํ›„**: ์ฝ”๋“œ ๋ณ€๊ฒฝ ์‹œ๋งˆ๋‹ค ์ „์ฒด ํ…Œ์ŠคํŠธ ์‹คํ–‰ +- **๋ฐฐํฌ ์ „**: ํ”„๋กœ๋•์…˜ ๋ฐฐํฌ ์ „ ํ•„์ˆ˜ ๊ฒ€์ฆ +- **์ •๊ธฐ ์ ๊ฒ€**: ์ฃผ 1ํšŒ ๋ฐ์ดํ„ฐ ์ •ํ™•์„ฑ ํ™•์ธ +- **๋ฌธ์ œ ๋ฐœ์ƒ ์‹œ**: ์ฆ‰์‹œ ํ•ด๋‹น ์˜์—ญ ํ…Œ์ŠคํŠธ ์‹คํ–‰ + +### ๐Ÿ› ๏ธ ํ™•์žฅ ๊ฐ€๋Šฅ์„ฑ +- ์ƒˆ๋กœ์šด ์ข…๋ชฉ ์ถ”๊ฐ€ ํ…Œ์ŠคํŠธ +- ์ถ”๊ฐ€ ์žฌ๋ฌด ์ง€ํ‘œ ๊ฒ€์ฆ +- ์„ฑ๋Šฅ ๋ฒค์น˜๋งˆํฌ ํ…Œ์ŠคํŠธ +- ์—๋Ÿฌ ์ผ€์ด์Šค ์‹œ๋‚˜๋ฆฌ์˜ค ํ…Œ์ŠคํŠธ + +## ๐Ÿ“ˆ ์‹œ์Šคํ…œ ์‹ ๋ขฐ์„ฑ ํ™•๋ณด + +์ด ํ…Œ์ŠคํŠธ ์Šค์œ„ํŠธ๋ฅผ ํ†ตํ•ด Stock Oracle ์‹œ์Šคํ…œ์ด ๋‹ค์Œ์„ ๋ณด์žฅํ•จ์„ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค: + +โœ… **๋ฐ์ดํ„ฐ ์ •ํ™•์„ฑ**: ์‹ค์ œ SEC ๋ฐ์ดํ„ฐ๋ฅผ 100% ์ •ํ™•ํ•˜๊ฒŒ ์ฒ˜๋ฆฌ +โœ… **์‹œ์Šคํ…œ ์•ˆ์ •์„ฑ**: ๋ชจ๋“  ํ•ต์‹ฌ ๊ธฐ๋Šฅ์ด ์•ˆ์ •์ ์œผ๋กœ ์ž‘๋™ +โœ… **์™ธ๋ถ€ ์˜์กด์„ฑ**: yfinance_plus์™€์˜ ์™„๋ฒฝํ•œ ํ˜ธํ™˜์„ฑ +โœ… **์žฅ๊ธฐ๊ฐ„ ์ผ๊ด€์„ฑ**: 5๋…„๊ฐ„ ๋ฐ์ดํ„ฐ์˜ ์ผ๊ด€๋œ ์ •ํ™•๋„ +โœ… **์ž๋™ํ™” ์™„์„ฑ**: ์›ํด๋ฆญ ํ…Œ์ŠคํŠธ ๋ฐ ๊ฒ€์ฆ ์‹œ์Šคํ…œ + +--- + +**๐ŸŽฏ ๊ฒฐ๋ก **: Stock Oracle ์‹œ์Šคํ…œ์ด ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์—์„œ ์‹ ๋ขฐํ•  ์ˆ˜ ์žˆ๋Š” ์‹ค์ œ SEC ์žฌ๋ฌด ๋ฐ์ดํ„ฐ๋ฅผ ์ œ๊ณตํ•  ์ค€๋น„๊ฐ€ ์™„์ „ํžˆ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..5f19b37 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Test package \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..fcf8ed8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,116 @@ +""" +Test configuration and fixtures +""" + +import pytest +import asyncio +from typing import Generator, AsyncGenerator +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +import tempfile +import os + +from app.main import app +from app.core.database import get_db, Base +from app.core.config import settings + +# Test database URL (use in-memory SQLite for tests) +TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" + +# Create test engine +test_engine = create_async_engine( + TEST_DATABASE_URL, + echo=False, + future=True +) + +# Create test session factory +TestSessionLocal = sessionmaker( + test_engine, + class_=AsyncSession, + expire_on_commit=False +) + +@pytest.fixture(scope="session") +def event_loop(): + """Create an instance of the default event loop for the test session.""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + +@pytest.fixture(scope="function") +async def db_session() -> AsyncGenerator[AsyncSession, None]: + """Create a fresh database session for each test.""" + # Create tables + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + # Create session + async with TestSessionLocal() as session: + yield session + + # Drop tables + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + +@pytest.fixture(scope="function") +def client() -> Generator[TestClient, None, None]: + """Create a test client with in-memory database.""" + + # Create test engine and session + test_engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) + TestSession = sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + + async def override_get_db(): + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with TestSession() as session: + yield session + + app.dependency_overrides[get_db] = override_get_db + + with TestClient(app) as test_client: + yield test_client + + app.dependency_overrides.clear() + +@pytest.fixture +def sample_financial_data(): + """Sample financial data for testing""" + return { + "ticker": "AAPL", + "start_date": "2023-01-01T00:00:00", + "end_date": "2023-12-31T23:59:59", + "period_type": "quarterly", + "include_metrics": True, + "force_refresh": False + } + +@pytest.fixture +def sample_ohlcv_data(): + """Sample OHLCV data for testing""" + return { + "ticker": "AAPL", + "start_date": "2023-01-01T00:00:00", + "end_date": "2023-12-31T23:59:59", + "interval": "1d" + } + +@pytest.fixture +def valid_migration_key(): + """Valid migration API key for testing""" + return settings.MIGRATION_API_KEY + +@pytest.fixture +def mock_company_data(): + """Mock company data for testing""" + return { + "ticker": "AAPL", + "name": "Apple Inc.", + "cik": "0000320193", + "sector": "Technology", + "industry": "Consumer Electronics", + "business_description": "Technology company" + } \ No newline at end of file diff --git a/tests/run_all_tests.py b/tests/run_all_tests.py new file mode 100755 index 0000000..0acf599 --- /dev/null +++ b/tests/run_all_tests.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +""" +Stock Oracle ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ์Šค์œ„ํŠธ +๋ชจ๋“  ํ…Œ์ŠคํŠธ๋ฅผ ์ˆœ์ฐจ์ ์œผ๋กœ ์‹คํ–‰ํ•˜๊ณ  ๊ฒฐ๊ณผ๋ฅผ ์š”์•ฝํ•ฉ๋‹ˆ๋‹ค. +""" + +import subprocess +import sys +import time +from datetime import datetime +from pathlib import Path + +# ํ…Œ์ŠคํŠธ ํŒŒ์ผ๋“ค๊ณผ ์„ค๋ช… +TEST_SUITE = [ + { + "file": "test_yfinance_fallback.py", + "name": "YFinance Plus ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ", + "description": "yfinance_plus ๋ชจ๋“ˆ์ด ์ •์ƒ์ ์œผ๋กœ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธ" + }, + { + "file": "test_simple_real_data.py", + "name": "์‹ค์ œ SEC ๋ฐ์ดํ„ฐ ๊ธฐ๋ณธ ํ…Œ์ŠคํŠธ", + "description": "์‹ค์ œ SEC ์žฌ๋ฌด ๋ฐ์ดํ„ฐ๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ๊ฐ€์ ธ์™€์ง€๋Š”์ง€ ํ™•์ธ" + }, + { + "file": "test_real_sec_vs_yfinance_plus.py", + "name": "Stock Oracle vs YFinance Plus ๋น„๊ต", + "description": "Stock Oracle์˜ ์‹ค์ œ ๋ฐ์ดํ„ฐ์™€ yfinance_plus ์ง์ ‘ ํ˜ธ์ถœ ๊ฒฐ๊ณผ ๋น„๊ต" + }, + { + "file": "test_5year_comparison.py", + "name": "5๋…„ ๋ถ„๊ธฐ๋ณ„ ์ •ํ™•๋„ ํ…Œ์ŠคํŠธ", + "description": "๋™์ผํ•œ ๋ถ„๊ธฐ ๋ฐ์ดํ„ฐ์˜ ์ •ํ™•๋„๋ฅผ 5๋…„๊ฐ„ ๋น„๊ต ๊ฒ€์ฆ" + } +] + +def print_header(): + """ํ…Œ์ŠคํŠธ ์‹œ์ž‘ ํ—ค๋” ์ถœ๋ ฅ""" + print("=" * 80) + print("๐Ÿงช Stock Oracle ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ์Šค์œ„ํŠธ") + print("=" * 80) + print(f"ํ…Œ์ŠคํŠธ ์‹œ์ž‘ ์‹œ๊ฐ„: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"์ด ํ…Œ์ŠคํŠธ ๊ฐœ์ˆ˜: {len(TEST_SUITE)}") + print() + +def run_test(test_info: dict) -> dict: + """๋‹จ์ผ ํ…Œ์ŠคํŠธ ์‹คํ–‰""" + test_file = test_info["file"] + test_name = test_info["name"] + test_desc = test_info["description"] + + print(f"๐Ÿ” [{test_name}]") + print(f" ๐Ÿ“„ ํŒŒ์ผ: {test_file}") + print(f" ๐Ÿ“ ์„ค๋ช…: {test_desc}") + print(" โณ ์‹คํ–‰ ์ค‘...") + + start_time = time.time() + + try: + # ํ…Œ์ŠคํŠธ ์‹คํ–‰ + result = subprocess.run( + [sys.executable, test_file], + cwd=Path(__file__).parent, + capture_output=True, + text=True, + timeout=300 # 5๋ถ„ ํƒ€์ž„์•„์›ƒ + ) + + end_time = time.time() + duration = end_time - start_time + + if result.returncode == 0: + status = "โœ… ์„ฑ๊ณต" + success = True + else: + status = "โŒ ์‹คํŒจ" + success = False + + print(f" {status} (์‹คํ–‰์‹œ๊ฐ„: {duration:.1f}์ดˆ)") + + if not success: + print(" ๐Ÿšจ ์—๋Ÿฌ ์ถœ๋ ฅ:") + print(" " + "\n ".join(result.stderr.split("\n")[:10])) # ์ฒ˜์Œ 10์ค„๋งŒ + + print() + + return { + "name": test_name, + "file": test_file, + "success": success, + "duration": duration, + "stdout": result.stdout, + "stderr": result.stderr, + "returncode": result.returncode + } + + except subprocess.TimeoutExpired: + print(" โฐ ํƒ€์ž„์•„์›ƒ (5๋ถ„ ์ดˆ๊ณผ)") + print() + return { + "name": test_name, + "file": test_file, + "success": False, + "duration": 300, + "stdout": "", + "stderr": "Timeout after 5 minutes", + "returncode": -1 + } + + except Exception as e: + print(f" ๐Ÿ’ฅ ์˜ˆ์™ธ ๋ฐœ์ƒ: {str(e)}") + print() + return { + "name": test_name, + "file": test_file, + "success": False, + "duration": 0, + "stdout": "", + "stderr": str(e), + "returncode": -2 + } + +def print_summary(results: list): + """ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ ์š”์•ฝ ์ถœ๋ ฅ""" + print("=" * 80) + print("๐Ÿ“Š ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ ์š”์•ฝ") + print("=" * 80) + + total_tests = len(results) + successful_tests = sum(1 for r in results if r["success"]) + failed_tests = total_tests - successful_tests + total_duration = sum(r["duration"] for r in results) + + print(f"์ด ํ…Œ์ŠคํŠธ: {total_tests}") + print(f"์„ฑ๊ณต: {successful_tests} โœ…") + print(f"์‹คํŒจ: {failed_tests} โŒ") + print(f"์„ฑ๊ณต๋ฅ : {(successful_tests/total_tests)*100:.1f}%") + print(f"์ด ์‹คํ–‰์‹œ๊ฐ„: {total_duration:.1f}์ดˆ") + print() + + # ๊ฐœ๋ณ„ ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ + print("๐Ÿ“‹ ๊ฐœ๋ณ„ ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ:") + print("-" * 80) + for i, result in enumerate(results, 1): + status_icon = "โœ…" if result["success"] else "โŒ" + print(f"{i:2d}. {status_icon} {result['name']}") + print(f" ๐Ÿ“„ {result['file']}") + print(f" โฑ๏ธ {result['duration']:.1f}์ดˆ") + + if not result["success"]: + print(f" ๐Ÿšจ ์‹คํŒจ ์‚ฌ์œ : {result['stderr'][:100]}...") + print() + + # ์‹คํŒจํ•œ ํ…Œ์ŠคํŠธ๊ฐ€ ์žˆ๋‹ค๋ฉด ์ƒ์„ธ ์ •๋ณด ์ถœ๋ ฅ + if failed_tests > 0: + print("๐Ÿ” ์‹คํŒจ ํ…Œ์ŠคํŠธ ์ƒ์„ธ ์ •๋ณด:") + print("-" * 80) + for result in results: + if not result["success"]: + print(f"โŒ {result['name']} ({result['file']})") + print(" ํ‘œ์ค€ ์ถœ๋ ฅ:") + print(" " + "\n ".join(result["stdout"].split("\n")[:5])) + print(" ํ‘œ์ค€ ์—๋Ÿฌ:") + print(" " + "\n ".join(result["stderr"].split("\n")[:5])) + print() + +def check_prerequisites(): + """ํ…Œ์ŠคํŠธ ์‹คํ–‰ ์ „ ํ•„์ˆ˜ ์กฐ๊ฑด ํ™•์ธ""" + print("๐Ÿ”ง ํ•„์ˆ˜ ์กฐ๊ฑด ํ™•์ธ ์ค‘...") + + # Docker ์ปจํ…Œ์ด๋„ˆ ์‹คํ–‰ ํ™•์ธ + try: + result = subprocess.run( + ["curl", "-s", "http://localhost:18001/docs"], + capture_output=True, + timeout=5 + ) + if result.returncode != 0: + print("โŒ Stock Oracle API ์„œ๋ฒ„๊ฐ€ ์‹คํ–‰๋˜์ง€ ์•Š๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.") + print(" docker-compose up -d ๋ช…๋ น์œผ๋กœ ์„œ๋ฒ„๋ฅผ ์‹œ์ž‘ํ•ด์ฃผ์„ธ์š”.") + return False + else: + print("โœ… Stock Oracle API ์„œ๋ฒ„ ์‹คํ–‰ ํ™•์ธ") + except: + print("โŒ Stock Oracle API ์„œ๋ฒ„ ์—ฐ๊ฒฐ ์‹คํŒจ") + return False + + # ํ•„์ˆ˜ Python ํŒจํ‚ค์ง€ ํ™•์ธ + required_packages = ["requests", "yfinance_plus", "pandas"] + for package in required_packages: + try: + __import__(package) + print(f"โœ… {package} ํŒจํ‚ค์ง€ ์„ค์น˜ ํ™•์ธ") + except ImportError: + print(f"โŒ {package} ํŒจํ‚ค์ง€๊ฐ€ ์„ค์น˜๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.") + return False + + print("โœ… ๋ชจ๋“  ํ•„์ˆ˜ ์กฐ๊ฑด์ด ๋งŒ์กฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + print() + return True + +def main(): + """๋ฉ”์ธ ํ…Œ์ŠคํŠธ ์‹คํ–‰ ํ•จ์ˆ˜""" + print_header() + + # ํ•„์ˆ˜ ์กฐ๊ฑด ํ™•์ธ + if not check_prerequisites(): + print("โŒ ํ•„์ˆ˜ ์กฐ๊ฑด์ด ๋งŒ์กฑ๋˜์ง€ ์•Š์•„ ํ…Œ์ŠคํŠธ๋ฅผ ์ค‘๋‹จํ•ฉ๋‹ˆ๋‹ค.") + sys.exit(1) + + # ํ…Œ์ŠคํŠธ ์‹คํ–‰ + results = [] + + for i, test_info in enumerate(TEST_SUITE, 1): + print(f"[{i}/{len(TEST_SUITE)}] ", end="") + result = run_test(test_info) + results.append(result) + + # ์‹คํŒจํ•œ ํ…Œ์ŠคํŠธ๊ฐ€ ์žˆ์œผ๋ฉด ์ž ์‹œ ๋Œ€๊ธฐ + if not result["success"]: + time.sleep(2) + + # ๊ฒฐ๊ณผ ์š”์•ฝ + print_summary(results) + + # ์ข…๋ฃŒ ์ฝ”๋“œ ์„ค์ • + failed_count = sum(1 for r in results if not r["success"]) + if failed_count > 0: + print(f"โŒ {failed_count}๊ฐœ์˜ ํ…Œ์ŠคํŠธ๊ฐ€ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + sys.exit(1) + else: + print("๐ŸŽ‰ ๋ชจ๋“  ํ…Œ์ŠคํŠธ๊ฐ€ ์„ฑ๊ณตํ–ˆ์Šต๋‹ˆ๋‹ค!") + sys.exit(0) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_15year_financial_data.py b/tests/test_15year_financial_data.py new file mode 100755 index 0000000..6db8141 --- /dev/null +++ b/tests/test_15year_financial_data.py @@ -0,0 +1,453 @@ +#!/usr/bin/env python3 +""" +15๋…„ ์žฌ๋ฌด์žฌํ‘œ ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ ์ข…ํ•ฉ ํ…Œ์ŠคํŠธ +๋‹ค์–‘ํ•œ ๋ฐฉ๋ฒ•์œผ๋กœ API๋ฅผ ํ…Œ์ŠคํŠธํ•˜๊ณ  ๋ฌธ์ œ์ ์„ ์ฐพ์•„ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค. +""" + +import requests +import yfinance_plus as yf +from datetime import datetime, timedelta +import json +import time +from typing import Dict, Any, List + +# API ์„ค์ • +API_URL = "http://localhost:18001/api/v1" + +def print_section(title: str): + """์„น์…˜ ํ—ค๋” ์ถœ๋ ฅ""" + print(f"\n{'='*80}") + print(f"๐Ÿ” {title}") + print(f"{'='*80}") + +def print_test(test_name: str): + """ํ…Œ์ŠคํŠธ ํ—ค๋” ์ถœ๋ ฅ""" + print(f"\n๐Ÿ“‹ {test_name}") + print("-" * 60) + +def test_yfinance_plus_availability(): + """YFinance Plus ์‚ฌ์šฉ ๊ฐ€๋Šฅ์„ฑ ํ…Œ์ŠคํŠธ""" + print_test("YFinance Plus ๋ชจ๋“ˆ ํ…Œ์ŠคํŠธ") + + try: + # ๋ชจ๋“ˆ ์ž„ํฌํŠธ ํ…Œ์ŠคํŠธ + print("โœ… yfinance_plus ๋ชจ๋“ˆ ์ž„ํฌํŠธ ์„ฑ๊ณต") + + # ๊ธฐ๋ณธ ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ + stock = yf.Ticker("AAPL") + info = stock.info + print(f"โœ… AAPL ๊ธฐ๋ณธ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ ์„ฑ๊ณต: {info.get('longName', 'N/A')}") + + # ์žฌ๋ฌด์žฌํ‘œ ๊ฐ€์ ธ์˜ค๊ธฐ ํ…Œ์ŠคํŠธ + quarterly_income = stock.quarterly_income_stmt + print(f"โœ… ๋ถ„๊ธฐ๋ณ„ ์†์ต๊ณ„์‚ฐ์„œ: {quarterly_income.shape}") + + annual_income = stock.income_stmt + print(f"โœ… ์—ฐ๊ฐ„ ์†์ต๊ณ„์‚ฐ์„œ: {annual_income.shape}") + + # ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๊ธฐ๊ฐ„ ํ™•์ธ + if not quarterly_income.empty: + earliest = quarterly_income.columns[-1] + latest = quarterly_income.columns[0] + print(f"๐Ÿ“… ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ถ„๊ธฐ ๋ฐ์ดํ„ฐ ๊ธฐ๊ฐ„: {earliest} ~ {latest}") + + if not annual_income.empty: + earliest_annual = annual_income.columns[-1] + latest_annual = annual_income.columns[0] + print(f"๐Ÿ“… ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์—ฐ๊ฐ„ ๋ฐ์ดํ„ฐ ๊ธฐ๊ฐ„: {earliest_annual} ~ {latest_annual}") + + return True + + except Exception as e: + print(f"โŒ YFinance Plus ํ…Œ์ŠคํŠธ ์‹คํŒจ: {str(e)}") + return False + +def test_api_basic_connection(): + """API ๊ธฐ๋ณธ ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ""" + print_test("API ๊ธฐ๋ณธ ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ") + + try: + # Health check + response = requests.get(f"{API_URL}/health", timeout=10) + if response.status_code == 200: + print("โœ… Health check ์„ฑ๊ณต") + else: + print(f"โš ๏ธ Health check ์‘๋‹ต: {response.status_code}") + + # Database stats + response = requests.get(f"{API_URL}/database/stats", timeout=10) + if response.status_code == 200: + stats = response.json() + print(f"โœ… DB ํ†ต๊ณ„ ์กฐํšŒ ์„ฑ๊ณต") + print(f" - ์ด ํšŒ์‚ฌ ์ˆ˜: {stats.get('companies', {}).get('total', 0)}") + print(f" - ์žฌ๋ฌด ๋ฐ์ดํ„ฐ: {stats.get('financial_data', {}).get('total_records', 0)}") + print(f" - ์ฃผ๊ฐ€ ๋ฐ์ดํ„ฐ: {stats.get('price_data', {}).get('total_records', 0)}") + else: + print(f"โŒ DB ํ†ต๊ณ„ ์กฐํšŒ ์‹คํŒจ: {response.status_code}") + + return True + + except Exception as e: + print(f"โŒ API ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ ์‹คํŒจ: {str(e)}") + return False + +def test_current_financial_data(): + """ํ˜„์žฌ ์žฌ๋ฌด ๋ฐ์ดํ„ฐ ์กฐํšŒ ํ…Œ์ŠคํŠธ""" + print_test("ํ˜„์žฌ ์žฌ๋ฌด ๋ฐ์ดํ„ฐ ์กฐํšŒ ํ…Œ์ŠคํŠธ") + + tickers = ["AAPL", "MSFT", "GOOGL"] + + for ticker in tickers: + print(f"\n๐Ÿ“Š {ticker} ํ…Œ์ŠคํŠธ:") + + # ์ตœ๊ทผ 2๋…„ ๋ฐ์ดํ„ฐ ํ…Œ์ŠคํŠธ + request_data = { + "ticker": ticker, + "start_date": "2023-01-01", + "end_date": "2024-12-31", + "period_type": "all", + "include_metrics": True, + "force_refresh": False + } + + try: + response = requests.post( + f"{API_URL}/financial/data", + json=request_data, + timeout=30 + ) + + if response.status_code == 200: + data = response.json() + financial_data = data.get('financial_data', []) + print(f" โœ… ์„ฑ๊ณต: {len(financial_data)}๊ฐœ ๊ธฐ๊ฐ„ ๋ฐ์ดํ„ฐ") + + if financial_data: + real_data = sum(1 for d in financial_data if not d.get('is_estimated', True)) + estimated_data = len(financial_data) - real_data + print(f" ๐Ÿ“ˆ ์‹ค์ œ ๋ฐ์ดํ„ฐ: {real_data}, ์ถ”์ • ๋ฐ์ดํ„ฐ: {estimated_data}") + + # ์ตœ์‹  ๋ฐ์ดํ„ฐ ํ™•์ธ + latest = financial_data[-1] + print(f" ๐Ÿ“… ์ตœ์‹  ๊ธฐ๊ฐ„: {latest.get('period_date')}") + print(f" ๐Ÿ’ฐ ๋งค์ถœ: ${latest.get('revenue', 0):,.0f}") + print(f" ๐Ÿ“Š ๋ฐ์ดํ„ฐ ์†Œ์Šค: {latest.get('data_source')}") + else: + print(" โš ๏ธ ์žฌ๋ฌด ๋ฐ์ดํ„ฐ๊ฐ€ ๋น„์–ด์žˆ์Œ") + else: + print(f" โŒ ์‹คํŒจ: {response.status_code}") + print(f" Error: {response.text[:200]}") + + except Exception as e: + print(f" โŒ ์˜ˆ์™ธ: {str(e)}") + +def test_15year_historical_data(): + """15๋…„ ๊ณผ๊ฑฐ ๋ฐ์ดํ„ฐ ์กฐํšŒ ํ…Œ์ŠคํŠธ""" + print_test("15๋…„ ๊ณผ๊ฑฐ ์žฌ๋ฌด ๋ฐ์ดํ„ฐ ์กฐํšŒ ํ…Œ์ŠคํŠธ") + + # 15๋…„ ์ „๋ถ€ํ„ฐ ํ˜„์žฌ๊นŒ์ง€ + start_date = "2009-01-01" + end_date = "2024-12-31" + + tickers = ["AAPL", "MSFT"] + + for ticker in tickers: + print(f"\n๐Ÿ•ฐ๏ธ {ticker} 15๋…„ ๋ฐ์ดํ„ฐ ํ…Œ์ŠคํŠธ ({start_date} ~ {end_date}):") + + # ์—ฐ๊ฐ„ ๋ฐ์ดํ„ฐ๋กœ ํ…Œ์ŠคํŠธ + request_data = { + "ticker": ticker, + "start_date": start_date, + "end_date": end_date, + "period_type": "annual", + "include_metrics": True, + "force_refresh": True # ๊ฐ•์ œ ์ƒˆ๋กœ๊ณ ์นจ์œผ๋กœ ์‹ค์ œ ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ + } + + try: + print(f" โณ ์š”์ฒญ ์‹œ์ž‘... (์‹œ๊ฐ„์ด ์˜ค๋ž˜ ๊ฑธ๋ฆด ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค)") + start_time = time.time() + + response = requests.post( + f"{API_URL}/financial/data", + json=request_data, + timeout=120 # 2๋ถ„ ํƒ€์ž„์•„์›ƒ + ) + + end_time = time.time() + duration = end_time - start_time + + if response.status_code == 200: + data = response.json() + financial_data = data.get('financial_data', []) + print(f" โœ… ์„ฑ๊ณต: {len(financial_data)}๊ฐœ ์—ฐ๊ฐ„ ๋ฐ์ดํ„ฐ ({duration:.1f}์ดˆ)") + + if financial_data: + # ๋ฐ์ดํ„ฐ ํ’ˆ์งˆ ๋ถ„์„ + years = set() + real_count = 0 + estimated_count = 0 + + for item in financial_data: + year = item.get('period_date', '')[:4] + years.add(year) + if item.get('is_estimated', True): + estimated_count += 1 + else: + real_count += 1 + + print(f" ๐Ÿ“Š ์ปค๋ฒ„๋ฆฌ์ง€: {len(years)}๋…„ ({min(years)} ~ {max(years)})") + print(f" ๐Ÿ“ˆ ์‹ค์ œ ๋ฐ์ดํ„ฐ: {real_count}, ์ถ”์ • ๋ฐ์ดํ„ฐ: {estimated_count}") + + # ์ตœ์‹  ๋ฐ ๊ฐ€์žฅ ์˜ค๋ž˜๋œ ๋ฐ์ดํ„ฐ ํ™•์ธ + oldest = financial_data[0] + newest = financial_data[-1] + + print(f" ๐Ÿ“… ๊ฐ€์žฅ ์˜ค๋ž˜๋œ ๋ฐ์ดํ„ฐ: {oldest.get('period_date')} (๋งค์ถœ: ${oldest.get('revenue', 0):,.0f})") + print(f" ๐Ÿ“… ๊ฐ€์žฅ ์ตœ์‹  ๋ฐ์ดํ„ฐ: {newest.get('period_date')} (๋งค์ถœ: ${newest.get('revenue', 0):,.0f})") + + # ๋ฐ์ดํ„ฐ ์†Œ์Šค ๋ถ„ํฌ + sources = {} + for item in financial_data: + source = item.get('data_source', 'Unknown') + sources[source] = sources.get(source, 0) + 1 + + print(f" ๐Ÿ” ๋ฐ์ดํ„ฐ ์†Œ์Šค: {sources}") + + else: + print(" โš ๏ธ 15๋…„ ๋ฐ์ดํ„ฐ๊ฐ€ ๋น„์–ด์žˆ์Œ") + + else: + print(f" โŒ ์‹คํŒจ: {response.status_code} ({duration:.1f}์ดˆ)") + print(f" Error: {response.text[:500]}") + + except requests.exceptions.Timeout: + print(f" โฐ ํƒ€์ž„์•„์›ƒ: 2๋ถ„ ์ดˆ๊ณผ") + except Exception as e: + print(f" โŒ ์˜ˆ์™ธ: {str(e)}") + +def test_quarterly_vs_annual(): + """๋ถ„๊ธฐ๋ณ„ vs ์—ฐ๊ฐ„ ๋ฐ์ดํ„ฐ ๋น„๊ต ํ…Œ์ŠคํŠธ""" + print_test("๋ถ„๊ธฐ๋ณ„ vs ์—ฐ๊ฐ„ ๋ฐ์ดํ„ฐ ๋น„๊ต ํ…Œ์ŠคํŠธ") + + ticker = "AAPL" + period_range = { + "start_date": "2020-01-01", + "end_date": "2024-12-31" + } + + results = {} + + # ๋ถ„๊ธฐ๋ณ„ ๋ฐ ์—ฐ๊ฐ„ ๋ฐ์ดํ„ฐ ํ…Œ์ŠคํŠธ + for period_type in ["quarterly", "annual"]: + print(f"\n๐Ÿ“Š {ticker} {period_type} ๋ฐ์ดํ„ฐ:") + + request_data = { + "ticker": ticker, + "period_type": period_type, + "include_metrics": True, + "force_refresh": False, + **period_range + } + + try: + response = requests.post( + f"{API_URL}/financial/data", + json=request_data, + timeout=60 + ) + + if response.status_code == 200: + data = response.json() + financial_data = data.get('financial_data', []) + results[period_type] = financial_data + + print(f" โœ… ์„ฑ๊ณต: {len(financial_data)}๊ฐœ ๋ฐ์ดํ„ฐ") + + if financial_data: + real_count = sum(1 for d in financial_data if not d.get('is_estimated', True)) + print(f" ๐Ÿ“ˆ ์‹ค์ œ ๋ฐ์ดํ„ฐ: {real_count}/{len(financial_data)}") + + # ๋‚ ์งœ ๋ฒ”์œ„ ํ™•์ธ + dates = [d.get('period_date') for d in financial_data] + print(f" ๐Ÿ“… ๊ธฐ๊ฐ„: {min(dates)} ~ {max(dates)}") + + else: + print(f" โŒ ์‹คํŒจ: {response.status_code}") + results[period_type] = [] + + except Exception as e: + print(f" โŒ ์˜ˆ์™ธ: {str(e)}") + results[period_type] = [] + + # ๋น„๊ต ๋ถ„์„ + print(f"\n๐Ÿ” ๋น„๊ต ๋ถ„์„:") + quarterly_count = len(results.get('quarterly', [])) + annual_count = len(results.get('annual', [])) + + print(f" ๋ถ„๊ธฐ๋ณ„ ๋ฐ์ดํ„ฐ: {quarterly_count}๊ฐœ") + print(f" ์—ฐ๊ฐ„ ๋ฐ์ดํ„ฐ: {annual_count}๊ฐœ") + print(f" ์˜ˆ์ƒ ๋น„์œจ: {quarterly_count/4:.1f} โ‰ˆ {annual_count} (์ด๋ก ์ ์œผ๋กœ 4:1)") + +def test_different_error_scenarios(): + """๋‹ค์–‘ํ•œ ์—๋Ÿฌ ์‹œ๋‚˜๋ฆฌ์˜ค ํ…Œ์ŠคํŠธ""" + print_test("์—๋Ÿฌ ์‹œ๋‚˜๋ฆฌ์˜ค ํ…Œ์ŠคํŠธ") + + error_tests = [ + { + "name": "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ข…๋ชฉ", + "data": { + "ticker": "NONEXISTENT", + "start_date": "2023-01-01", + "end_date": "2023-12-31", + "period_type": "quarterly" + } + }, + { + "name": "์ž˜๋ชป๋œ ๋‚ ์งœ ํ˜•์‹", + "data": { + "ticker": "AAPL", + "start_date": "invalid-date", + "end_date": "2023-12-31", + "period_type": "quarterly" + } + }, + { + "name": "๋ฏธ๋ž˜ ๋‚ ์งœ", + "data": { + "ticker": "AAPL", + "start_date": "2030-01-01", + "end_date": "2030-12-31", + "period_type": "quarterly" + } + }, + { + "name": "๊ทน๋„๋กœ ์˜ค๋ž˜๋œ ๋‚ ์งœ", + "data": { + "ticker": "AAPL", + "start_date": "1990-01-01", + "end_date": "1990-12-31", + "period_type": "quarterly" + } + } + ] + + for test in error_tests: + print(f"\n๐Ÿšจ {test['name']} ํ…Œ์ŠคํŠธ:") + + try: + response = requests.post( + f"{API_URL}/financial/data", + json=test['data'], + timeout=30 + ) + + print(f" ์‘๋‹ต ์ฝ”๋“œ: {response.status_code}") + + if response.status_code == 200: + data = response.json() + financial_data = data.get('financial_data', []) + print(f" โœ… ์„ฑ๊ณต: {len(financial_data)}๊ฐœ ๋ฐ์ดํ„ฐ") + else: + print(f" โš ๏ธ ์—๋Ÿฌ ์‘๋‹ต: {response.text[:200]}") + + except Exception as e: + print(f" โŒ ์˜ˆ์™ธ: {str(e)}") + +def test_force_refresh_vs_cache(): + """๊ฐ•์ œ ์ƒˆ๋กœ๊ณ ์นจ vs ์บ์‹œ ๋น„๊ต ํ…Œ์ŠคํŠธ""" + print_test("๊ฐ•์ œ ์ƒˆ๋กœ๊ณ ์นจ vs ์บ์‹œ ๋น„๊ต ํ…Œ์ŠคํŠธ") + + ticker = "AAPL" + request_base = { + "ticker": ticker, + "start_date": "2023-01-01", + "end_date": "2024-12-31", + "period_type": "quarterly", + "include_metrics": True + } + + # ์บ์‹œ ์‚ฌ์šฉ ํ…Œ์ŠคํŠธ + print(f"\n๐Ÿ”„ ์บ์‹œ ์‚ฌ์šฉ ํ…Œ์ŠคํŠธ:") + start_time = time.time() + response1 = requests.post( + f"{API_URL}/financial/data", + json={**request_base, "force_refresh": False}, + timeout=60 + ) + cache_time = time.time() - start_time + + if response1.status_code == 200: + data1 = response1.json() + print(f" โœ… ์บ์‹œ ์š”์ฒญ ์„ฑ๊ณต: {len(data1.get('financial_data', []))}๊ฐœ ๋ฐ์ดํ„ฐ ({cache_time:.1f}์ดˆ)") + else: + print(f" โŒ ์บ์‹œ ์š”์ฒญ ์‹คํŒจ: {response1.status_code}") + + # ๊ฐ•์ œ ์ƒˆ๋กœ๊ณ ์นจ ํ…Œ์ŠคํŠธ + print(f"\n๐Ÿ”„ ๊ฐ•์ œ ์ƒˆ๋กœ๊ณ ์นจ ํ…Œ์ŠคํŠธ:") + start_time = time.time() + response2 = requests.post( + f"{API_URL}/financial/data", + json={**request_base, "force_refresh": True}, + timeout=120 + ) + refresh_time = time.time() - start_time + + if response2.status_code == 200: + data2 = response2.json() + print(f" โœ… ๊ฐ•์ œ ์ƒˆ๋กœ๊ณ ์นจ ์„ฑ๊ณต: {len(data2.get('financial_data', []))}๊ฐœ ๋ฐ์ดํ„ฐ ({refresh_time:.1f}์ดˆ)") + + # ์„ฑ๋Šฅ ๋น„๊ต + print(f"\nโšก ์„ฑ๋Šฅ ๋น„๊ต:") + print(f" ์บ์‹œ ์‚ฌ์šฉ: {cache_time:.1f}์ดˆ") + print(f" ๊ฐ•์ œ ์ƒˆ๋กœ๊ณ ์นจ: {refresh_time:.1f}์ดˆ") + print(f" ์†๋„ ์ฐจ์ด: {refresh_time/cache_time:.1f}๋ฐฐ ๋А๋ฆผ") + + else: + print(f" โŒ ๊ฐ•์ œ ์ƒˆ๋กœ๊ณ ์นจ ์‹คํŒจ: {response2.status_code}") + +def main(): + """๋ฉ”์ธ ํ…Œ์ŠคํŠธ ์‹คํ–‰""" + print_section("Stock Oracle 15๋…„ ์žฌ๋ฌด์žฌํ‘œ ๋ฐ์ดํ„ฐ ์ข…ํ•ฉ ํ…Œ์ŠคํŠธ") + + print(f"๐Ÿš€ ํ…Œ์ŠคํŠธ ์‹œ์ž‘ ์‹œ๊ฐ„: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"๐ŸŽฏ API URL: {API_URL}") + + # ํ…Œ์ŠคํŠธ ์‹คํ–‰ + tests = [ + ("YFinance Plus ์‚ฌ์šฉ ๊ฐ€๋Šฅ์„ฑ", test_yfinance_plus_availability), + ("API ๊ธฐ๋ณธ ์—ฐ๊ฒฐ", test_api_basic_connection), + ("ํ˜„์žฌ ์žฌ๋ฌด ๋ฐ์ดํ„ฐ ์กฐํšŒ", test_current_financial_data), + ("15๋…„ ๊ณผ๊ฑฐ ๋ฐ์ดํ„ฐ ์กฐํšŒ", test_15year_historical_data), + ("๋ถ„๊ธฐ๋ณ„ vs ์—ฐ๊ฐ„ ๋ฐ์ดํ„ฐ ๋น„๊ต", test_quarterly_vs_annual), + ("์—๋Ÿฌ ์‹œ๋‚˜๋ฆฌ์˜ค", test_different_error_scenarios), + ("๊ฐ•์ œ ์ƒˆ๋กœ๊ณ ์นจ vs ์บ์‹œ", test_force_refresh_vs_cache), + ] + + results = [] + + for test_name, test_func in tests: + print_section(test_name) + + try: + result = test_func() + results.append((test_name, "์„ฑ๊ณต" if result else "์‹คํŒจ")) + except Exception as e: + print(f"โŒ {test_name} ์‹คํ–‰ ์ค‘ ์˜ˆ์™ธ: {str(e)}") + results.append((test_name, "์˜ˆ์™ธ")) + + # ์ตœ์ข… ๊ฒฐ๊ณผ ์š”์•ฝ + print_section("ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ ์š”์•ฝ") + + for test_name, result in results: + status_icon = "โœ…" if result == "์„ฑ๊ณต" else "โŒ" + print(f"{status_icon} {test_name}: {result}") + + success_count = sum(1 for _, result in results if result == "์„ฑ๊ณต") + total_count = len(results) + + print(f"\n๐Ÿ“Š ์ „์ฒด ๊ฒฐ๊ณผ: {success_count}/{total_count} ์„ฑ๊ณต ({success_count/total_count*100:.1f}%)") + print(f"๐Ÿ• ํ…Œ์ŠคํŠธ ์™„๋ฃŒ ์‹œ๊ฐ„: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_5year_comparison.py b/tests/test_5year_comparison.py new file mode 100644 index 0000000..e939531 --- /dev/null +++ b/tests/test_5year_comparison.py @@ -0,0 +1,286 @@ +""" +5-Year Quarter-by-Quarter Comparison: Stock Oracle vs YFinance_plus +""" + +import requests +import yfinance_plus as yf +from datetime import datetime, timezone +import pandas as pd +import json +from typing import Dict, Any, List + +# API configuration +API_URL = "http://localhost:18001/api/v1" + +def get_yfinance_plus_5year_data(ticker: str) -> Dict[str, Any]: + """Get 5 years of quarterly financial data from yfinance_plus""" + print(f"\n=== Getting 5-year yfinance_plus data for {ticker} ===") + + stock = yf.Ticker(ticker) + + # Get quarterly financial statements + quarterly_income = stock.quarterly_income_stmt + quarterly_balance = stock.quarterly_balance_sheet + quarterly_cashflow = stock.quarterly_cashflow + + # Get company info + info = stock.info + print(f"Company: {info.get('longName', 'N/A')}") + print(f"Shares Outstanding: {info.get('sharesOutstanding', 0):,.0f}") + + print(f"Available quarters in income statement: {len(quarterly_income.columns)}") + print(f"Date range: {quarterly_income.columns[-1]} to {quarterly_income.columns[0]}") + + # Process quarterly data + quarterly_data = [] + for quarter_date in quarterly_income.columns: + try: + # Income statement + revenue = quarterly_income.loc['Total Revenue', quarter_date] if 'Total Revenue' in quarterly_income.index else None + net_income = quarterly_income.loc['Net Income', quarter_date] if 'Net Income' in quarterly_income.index else None + gross_profit = quarterly_income.loc['Gross Profit', quarter_date] if 'Gross Profit' in quarterly_income.index else None + operating_income = quarterly_income.loc['Operating Income', quarter_date] if 'Operating Income' in quarterly_income.index else None + + # Balance sheet (if available for this quarter) + total_assets = None + total_equity = None + total_debt = None + cash = None + + if not quarterly_balance.empty and quarter_date in quarterly_balance.columns: + total_assets = quarterly_balance.loc['Total Assets', quarter_date] if 'Total Assets' in quarterly_balance.index else None + total_equity = quarterly_balance.loc['Total Equity Gross Minority Interest', quarter_date] if 'Total Equity Gross Minority Interest' in quarterly_balance.index else None + total_debt = quarterly_balance.loc['Total Debt', quarter_date] if 'Total Debt' in quarterly_balance.index else None + cash = quarterly_balance.loc['Cash And Cash Equivalents', quarter_date] if 'Cash And Cash Equivalents' in quarterly_balance.index else None + + # Cash flow (if available for this quarter) + operating_cash_flow = None + capex = None + + if not quarterly_cashflow.empty and quarter_date in quarterly_cashflow.columns: + operating_cash_flow = quarterly_cashflow.loc['Operating Cash Flow', quarter_date] if 'Operating Cash Flow' in quarterly_cashflow.index else None + capex = quarterly_cashflow.loc['Capital Expenditure', quarter_date] if 'Capital Expenditure' in quarterly_cashflow.index else None + + # Calculate EPS + shares_outstanding = info.get('sharesOutstanding', info.get('impliedSharesOutstanding', 0)) + eps = net_income / shares_outstanding if net_income and shares_outstanding > 0 else None + + quarterly_data.append({ + 'quarter_date': quarter_date, + '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, + 'operating_cash_flow': operating_cash_flow, + 'capex': capex, + 'shares_outstanding': shares_outstanding + }) + + except Exception as e: + print(f"Error processing quarter {quarter_date}: {e}") + continue + + return { + 'info': info, + 'quarterly_data': quarterly_data + } + +def get_stock_oracle_5year_data(ticker: str) -> Dict[str, Any]: + """Get 5 years of quarterly financial data from Stock Oracle""" + print(f"\n=== Getting 5-year Stock Oracle data for {ticker} ===") + + # Request 5 years of data + start_date = "2019-01-01" + end_date = "2024-12-31" + + response = requests.post( + f"{API_URL}/financial/data", + json={ + "ticker": ticker, + "start_date": start_date, + "end_date": end_date, + "period_type": "all", + "include_metrics": True, + "force_refresh": True # Force refresh to get latest data + } + ) + + if response.status_code == 200: + data = response.json() + print(f"Company: {data['company']['name']}") + print(f"Number of periods: {len(data['financial_data'])}") + + # Check real vs estimated data + real_data_count = sum(1 for fd in data['financial_data'] if not fd.get('is_estimated', True)) + print(f"Real data periods: {real_data_count}") + print(f"Estimated data periods: {len(data['financial_data']) - real_data_count}") + + return data + else: + print(f"Error: {response.status_code} - {response.text}") + return None + +def find_matching_quarters(yfinance_data: List[Dict], oracle_data: List[Dict]) -> List[tuple]: + """Find quarters that exist in both datasets""" + + # Convert YFinance quarter dates to comparable format + yf_quarters = set() + for qd in yfinance_data: + quarter_date = qd['quarter_date'] + if hasattr(quarter_date, 'strftime'): + yf_quarters.add(quarter_date.strftime('%Y-%m-%d')) + else: + yf_quarters.add(str(quarter_date)[:10]) + + # Convert Oracle quarter dates to comparable format + oracle_quarters = set() + for fd in oracle_data: + period_date = fd['period_date'] + oracle_quarters.add(period_date[:10]) # Take YYYY-MM-DD part + + # Find intersection + common_quarters = yf_quarters.intersection(oracle_quarters) + print(f"\nCommon quarters found: {len(common_quarters)}") + print(f"YFinance quarters: {len(yf_quarters)}") + print(f"Oracle quarters: {len(oracle_quarters)}") + + return sorted(list(common_quarters)) + +def compare_quarter_by_quarter(yfinance_data: Dict, oracle_data: Dict, ticker: str): + """Compare quarter by quarter for exact matches""" + print(f"\n=== QUARTER-BY-QUARTER COMPARISON FOR {ticker} ===") + + if not oracle_data or 'financial_data' not in oracle_data: + print("No Stock Oracle data to compare") + return + + yf_quarterly = yfinance_data['quarterly_data'] + oracle_quarterly = oracle_data['financial_data'] + + # Find matching quarters + common_quarters = find_matching_quarters(yf_quarterly, oracle_quarterly) + + if not common_quarters: + print("No matching quarters found between datasets") + return + + print(f"\nComparing {len(common_quarters)} matching quarters:") + print("=" * 100) + + total_revenue_diff = 0 + total_net_income_diff = 0 + total_eps_diff = 0 + compared_quarters = 0 + + for quarter_str in common_quarters[:20]: # Limit to 20 quarters for readability + # Find YFinance data for this quarter + yf_quarter = None + for qd in yf_quarterly: + quarter_date = qd['quarter_date'] + if hasattr(quarter_date, 'strftime'): + qd_str = quarter_date.strftime('%Y-%m-%d') + else: + qd_str = str(quarter_date)[:10] + + if qd_str == quarter_str: + yf_quarter = qd + break + + # Find Oracle data for this quarter + oracle_quarter = None + for fd in oracle_quarterly: + if fd['period_date'][:10] == quarter_str: + oracle_quarter = fd + break + + if not yf_quarter or not oracle_quarter: + continue + + print(f"\n๐Ÿ“… Quarter: {quarter_str}") + print(f"Oracle Data Source: {oracle_quarter.get('data_source', 'Unknown')}") + print(f"Oracle Is Estimated: {oracle_quarter.get('is_estimated', 'Unknown')}") + + # Compare Revenue + oracle_revenue = oracle_quarter.get('revenue', 0) or 0 + yf_revenue = yf_quarter.get('revenue', 0) or 0 + + if oracle_revenue > 0 and yf_revenue > 0: + revenue_diff = ((oracle_revenue - yf_revenue) / yf_revenue) * 100 + total_revenue_diff += abs(revenue_diff) + print(f"๐Ÿ’ฐ Revenue:") + print(f" Oracle: ${oracle_revenue:,.0f}") + print(f" YFinance: ${yf_revenue:,.0f}") + print(f" Difference: {revenue_diff:+.2f}%") + + # Compare Net Income + oracle_net = oracle_quarter.get('net_income', 0) or 0 + yf_net = yf_quarter.get('net_income', 0) or 0 + + if oracle_net != 0 and yf_net != 0: + net_diff = ((oracle_net - yf_net) / yf_net) * 100 + total_net_income_diff += abs(net_diff) + print(f"๐Ÿ“ˆ Net Income:") + print(f" Oracle: ${oracle_net:,.0f}") + print(f" YFinance: ${yf_net:,.0f}") + print(f" Difference: {net_diff:+.2f}%") + + # Compare EPS + oracle_eps = oracle_quarter.get('eps', 0) or 0 + yf_eps = yf_quarter.get('eps', 0) or 0 + + if oracle_eps > 0 and yf_eps > 0: + eps_diff = ((oracle_eps - yf_eps) / yf_eps) * 100 + total_eps_diff += abs(eps_diff) + print(f"๐Ÿ“Š EPS:") + print(f" Oracle: ${oracle_eps:.2f}") + print(f" YFinance: ${yf_eps:.2f}") + print(f" Difference: {eps_diff:+.2f}%") + + # Compare Shares Outstanding + oracle_shares = oracle_quarter.get('shares_outstanding', 0) or 0 + yf_shares = yf_quarter.get('shares_outstanding', 0) or 0 + + if oracle_shares > 0 and yf_shares > 0: + shares_diff = ((oracle_shares - yf_shares) / yf_shares) * 100 + print(f"๐Ÿข Shares Outstanding:") + print(f" Oracle: {oracle_shares:,.0f}") + print(f" YFinance: {yf_shares:,.0f}") + print(f" Difference: {shares_diff:+.2f}%") + + compared_quarters += 1 + + print("-" * 80) + + # Summary statistics + if compared_quarters > 0: + print(f"\n๐Ÿ“Š SUMMARY STATISTICS ({compared_quarters} quarters)") + print("=" * 50) + print(f"Average Revenue Difference: {total_revenue_diff/compared_quarters:.2f}%") + print(f"Average Net Income Difference: {total_net_income_diff/compared_quarters:.2f}%") + print(f"Average EPS Difference: {total_eps_diff/compared_quarters:.2f}%") + +def main(): + """Main comparison function""" + tickers = ["AAPL", "MSFT"] + + for ticker in tickers: + print(f"\n{'='*100}") + print(f"5-YEAR QUARTER-BY-QUARTER COMPARISON: {ticker}") + print(f"{'='*100}") + + # Get data from both sources + yfinance_data = get_yfinance_plus_5year_data(ticker) + oracle_data = get_stock_oracle_5year_data(ticker) + + # Compare quarter by quarter + compare_quarter_by_quarter(yfinance_data, oracle_data, ticker) + + print(f"\n{'='*100}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_catalog.py b/tests/test_catalog.py new file mode 100644 index 0000000..efd5090 --- /dev/null +++ b/tests/test_catalog.py @@ -0,0 +1,189 @@ +""" +Test data catalog endpoint +""" + +import pytest +from fastapi.testclient import TestClient + +def test_get_catalog(client: TestClient): + """Test data catalog endpoint""" + response = client.get("/api/v1/metadata/catalog") + + assert response.status_code == 200 + data = response.json() + + # Check response structure + assert "categories" in data + assert "last_updated" in data + + # Check categories structure + categories = data["categories"] + assert isinstance(categories, dict) + + # Check expected categories exist + expected_categories = [ + "Company Information", + "Income Statement", + "Balance Sheet", + "Cash Flow Statement", + "Valuation Ratios", + "Profitability Metrics", + "Growth Metrics", + "Liquidity & Solvency", + "Efficiency Metrics", + "Market Data (Future)" + ] + + for category in expected_categories: + assert category in categories + +def test_catalog_category_structure(client: TestClient): + """Test catalog category structure""" + response = client.get("/api/v1/metadata/catalog") + + assert response.status_code == 200 + data = response.json() + + categories = data["categories"] + + # Check each category has proper structure + for category_name, items in categories.items(): + assert isinstance(items, list) + assert len(items) > 0 + + # Check each item structure + for item in items: + assert "field_name" in item + assert "description" in item + assert "data_type" in item + assert "source" in item + + # Optional fields + assert "unit" in item # Can be null + assert "calculation" in item # Can be null + + # Check data types + assert isinstance(item["field_name"], str) + assert isinstance(item["description"], str) + assert isinstance(item["data_type"], str) + assert isinstance(item["source"], str) + +def test_catalog_specific_fields(client: TestClient): + """Test specific fields in catalog""" + response = client.get("/api/v1/metadata/catalog") + + assert response.status_code == 200 + data = response.json() + + categories = data["categories"] + + # Check some specific fields exist + company_info = categories["Company Information"] + field_names = [item["field_name"] for item in company_info] + + assert "ticker" in field_names + assert "name" in field_names + assert "cik" in field_names + assert "sector" in field_names + assert "industry" in field_names + + # Check income statement fields + income_statement = categories["Income Statement"] + income_fields = [item["field_name"] for item in income_statement] + + assert "revenue" in income_fields + assert "gross_profit" in income_fields + assert "operating_income" in income_fields + assert "net_income" in income_fields + assert "eps" in income_fields + +def test_catalog_valuation_ratios(client: TestClient): + """Test valuation ratios in catalog""" + response = client.get("/api/v1/metadata/catalog") + + assert response.status_code == 200 + data = response.json() + + categories = data["categories"] + valuation_ratios = categories["Valuation Ratios"] + + ratio_names = [item["field_name"] for item in valuation_ratios] + + # Check key ratios exist + assert "pe_ratio" in ratio_names + assert "pb_ratio" in ratio_names + assert "ps_ratio" in ratio_names + assert "ev_ebitda" in ratio_names + + # Check they have calculations + for item in valuation_ratios: + if item["field_name"] in ["pe_ratio", "pb_ratio", "ps_ratio", "ev_ebitda"]: + assert item["calculation"] is not None + assert len(item["calculation"]) > 0 + +def test_catalog_profitability_metrics(client: TestClient): + """Test profitability metrics in catalog""" + response = client.get("/api/v1/metadata/catalog") + + assert response.status_code == 200 + data = response.json() + + categories = data["categories"] + profitability = categories["Profitability Metrics"] + + profit_names = [item["field_name"] for item in profitability] + + # Check key profitability metrics + assert "roe" in profit_names + assert "roa" in profit_names + assert "gross_margin" in profit_names + assert "operating_margin" in profit_names + assert "net_margin" in profit_names + + # Check they have percentage units + for item in profitability: + if "margin" in item["field_name"] or item["field_name"] in ["roe", "roa"]: + assert item["unit"] == "percentage" + +def test_catalog_market_data_future(client: TestClient): + """Test future market data fields in catalog""" + response = client.get("/api/v1/metadata/catalog") + + assert response.status_code == 200 + data = response.json() + + categories = data["categories"] + market_data = categories["Market Data (Future)"] + + market_names = [item["field_name"] for item in market_data] + + # Check future fields exist + assert "market_cap" in market_names + assert "forward_pe" in market_names + assert "peg_ratio" in market_names + assert "beta" in market_names + + # Check they indicate external data requirement + for item in market_data: + if item["field_name"] in ["forward_pe", "peg_ratio", "beta"]: + assert "external" in item["source"].lower() or "required" in item["source"].lower() + +def test_catalog_response_format(client: TestClient): + """Test catalog response format""" + response = client.get("/api/v1/metadata/catalog") + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + + data = response.json() + + # Check timestamp format + assert "last_updated" in data + assert isinstance(data["last_updated"], str) + + # Should be ISO format datetime + from datetime import datetime + try: + datetime.fromisoformat(data["last_updated"].replace('Z', '+00:00')) + except ValueError: + pytest.fail("last_updated is not in valid ISO format") \ No newline at end of file diff --git a/tests/test_final_verification.py b/tests/test_final_verification.py new file mode 100644 index 0000000..090b054 --- /dev/null +++ b/tests/test_final_verification.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +""" +Final verification test for 15-year financial data fixes +Tests all the key improvements made to resolve the issues +""" + +import requests +import json +from datetime import datetime +from typing import Dict, Any + +# API configuration +API_URL = "http://localhost:18001/api/v1" + +def test_database_cleanup(): + """Test database cleanup functionality""" + print("๐Ÿงน Testing database cleanup...") + + response = requests.post(f"{API_URL}/database/cleanup/duplicates") + if response.status_code == 200: + print(" โœ… Database cleanup successful") + return True + else: + print(f" โŒ Database cleanup failed: {response.status_code}") + return False + +def test_multiple_tickers_quarterly(): + """Test quarterly data for multiple tickers""" + print("\n๐Ÿ“Š Testing quarterly data for multiple tickers...") + + tickers = ["AAPL", "MSFT", "GOOGL"] + results = {} + + for ticker in tickers: + request_data = { + "ticker": ticker, + "start_date": "2023-01-01", + "end_date": "2024-12-31", + "period_type": "quarterly", + "include_metrics": True, + "force_refresh": False + } + + try: + response = requests.post(f"{API_URL}/financial/data", json=request_data, timeout=30) + + if response.status_code == 200: + data = response.json() + financial_data = data.get('financial_data', []) + results[ticker] = { + 'success': True, + 'records': len(financial_data), + 'real_data': sum(1 for d in financial_data if not d.get('is_estimated', True)) + } + print(f" โœ… {ticker}: {len(financial_data)} records, {results[ticker]['real_data']} real data") + else: + results[ticker] = {'success': False, 'error': response.status_code} + print(f" โŒ {ticker}: Failed with {response.status_code}") + + except Exception as e: + results[ticker] = {'success': False, 'error': str(e)} + print(f" โŒ {ticker}: Exception - {str(e)}") + + success_count = sum(1 for r in results.values() if r.get('success', False)) + print(f" ๐Ÿ“Š Summary: {success_count}/{len(tickers)} tickers successful") + + return success_count == len(tickers) + +def test_historical_data_range(): + """Test historical data requests with different ranges""" + print("\n๐Ÿ•ฐ๏ธ Testing historical data ranges...") + + test_cases = [ + { + "name": "3-year quarterly", + "ticker": "AAPL", + "start_date": "2022-01-01", + "end_date": "2024-12-31", + "period_type": "quarterly", + "expected_min": 8 # At least 8 quarters + }, + { + "name": "5-year annual", + "ticker": "AAPL", + "start_date": "2020-01-01", + "end_date": "2024-12-31", + "period_type": "annual", + "expected_min": 4 # At least 4 years (yfinance_plus limitation) + }, + { + "name": "15-year annual (auto-switch)", + "ticker": "MSFT", + "start_date": "2009-01-01", + "end_date": "2024-12-31", + "period_type": "annual", + "expected_min": 3 # At least 3 years due to yfinance_plus limitations + } + ] + + results = [] + + for test_case in test_cases: + print(f" ๐Ÿ” Testing {test_case['name']}...") + + request_data = { + "ticker": test_case["ticker"], + "start_date": test_case["start_date"], + "end_date": test_case["end_date"], + "period_type": test_case["period_type"], + "include_metrics": True, + "force_refresh": True + } + + try: + response = requests.post(f"{API_URL}/financial/data", json=request_data, timeout=60) + + if response.status_code == 200: + data = response.json() + financial_data = data.get('financial_data', []) + + if len(financial_data) >= test_case["expected_min"]: + print(f" โœ… Success: {len(financial_data)} records (expected โ‰ฅ{test_case['expected_min']})") + + # Check data quality + real_data = sum(1 for d in financial_data if not d.get('is_estimated', True)) + years_covered = set() + for d in financial_data: + year = d.get('period_date', '')[:4] + years_covered.add(year) + + print(f" ๐Ÿ“ˆ Real data: {real_data}/{len(financial_data)}") + print(f" ๐Ÿ“… Years: {sorted(years_covered)}") + results.append(True) + else: + print(f" โŒ Insufficient data: {len(financial_data)} < {test_case['expected_min']}") + results.append(False) + else: + print(f" โŒ Request failed: {response.status_code}") + results.append(False) + + except Exception as e: + print(f" โŒ Exception: {str(e)}") + results.append(False) + + success_count = sum(results) + print(f" ๐Ÿ“Š Summary: {success_count}/{len(test_cases)} test cases passed") + + return success_count == len(test_cases) + +def test_performance_improvements(): + """Test performance and caching""" + print("\nโšก Testing performance improvements...") + + ticker = "GOOGL" + request_data = { + "ticker": ticker, + "start_date": "2023-01-01", + "end_date": "2024-12-31", + "period_type": "quarterly", + "include_metrics": True + } + + # Test cache usage (should be fast) + print(" ๐Ÿ”„ Testing cache usage...") + start_time = datetime.now() + + request_data["force_refresh"] = False + response1 = requests.post(f"{API_URL}/financial/data", json=request_data, timeout=30) + + cache_time = (datetime.now() - start_time).total_seconds() + + if response1.status_code == 200: + data1 = response1.json() + print(f" โœ… Cache request: {len(data1.get('financial_data', []))} records in {cache_time:.1f}s") + cache_success = True + else: + print(f" โŒ Cache request failed: {response1.status_code}") + cache_success = False + + # Test force refresh (should work but be slower) + print(" ๐Ÿ”„ Testing force refresh...") + start_time = datetime.now() + + request_data["force_refresh"] = True + response2 = requests.post(f"{API_URL}/financial/data", json=request_data, timeout=60) + + refresh_time = (datetime.now() - start_time).total_seconds() + + if response2.status_code == 200: + data2 = response2.json() + print(f" โœ… Refresh request: {len(data2.get('financial_data', []))} records in {refresh_time:.1f}s") + refresh_success = True + + # Performance comparison + if cache_success and refresh_time > cache_time: + speed_diff = refresh_time / cache_time if cache_time > 0 else 1 + print(f" โšก Performance: Cache {speed_diff:.1f}x faster than refresh") + + else: + print(f" โŒ Refresh request failed: {response2.status_code}") + refresh_success = False + + return cache_success and refresh_success + +def test_error_handling(): + """Test error handling and validation""" + print("\n๐Ÿšจ Testing error handling...") + + error_tests = [ + { + "name": "Invalid ticker", + "data": {"ticker": "INVALID", "start_date": "2023-01-01", "end_date": "2023-12-31", "period_type": "quarterly"}, + "expected_codes": [422, 400] + }, + { + "name": "Future dates", + "data": {"ticker": "AAPL", "start_date": "2030-01-01", "end_date": "2030-12-31", "period_type": "quarterly"}, + "expected_codes": [400] + }, + { + "name": "Very old dates", + "data": {"ticker": "AAPL", "start_date": "1990-01-01", "end_date": "1990-12-31", "period_type": "quarterly"}, + "expected_codes": [400] + } + ] + + results = [] + + for test in error_tests: + try: + response = requests.post(f"{API_URL}/financial/data", json=test["data"], timeout=30) + + if response.status_code in test["expected_codes"]: + print(f" โœ… {test['name']}: Properly handled ({response.status_code})") + results.append(True) + else: + print(f" โŒ {test['name']}: Unexpected response ({response.status_code})") + results.append(False) + + except Exception as e: + print(f" โŒ {test['name']}: Exception - {str(e)}") + results.append(False) + + success_count = sum(results) + print(f" ๐Ÿ“Š Summary: {success_count}/{len(error_tests)} error cases handled correctly") + + return success_count == len(error_tests) + +def main(): + """Run all verification tests""" + print("๐Ÿ”ง Stock Oracle 15-Year Financial Data Fix Verification") + print("=" * 70) + print(f"๐Ÿš€ Test started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"๐ŸŽฏ API URL: {API_URL}") + + # Run all tests + tests = [ + ("Database Cleanup", test_database_cleanup), + ("Multiple Tickers Quarterly", test_multiple_tickers_quarterly), + ("Historical Data Range", test_historical_data_range), + ("Performance Improvements", test_performance_improvements), + ("Error Handling", test_error_handling), + ] + + results = [] + + for test_name, test_func in tests: + print(f"\n{'='*70}") + print(f"๐Ÿ” {test_name}") + print('='*70) + + try: + result = test_func() + results.append((test_name, "PASS" if result else "FAIL")) + except Exception as e: + print(f"โŒ {test_name} threw exception: {str(e)}") + results.append((test_name, "ERROR")) + + # Final summary + print(f"\n{'='*70}") + print("๐Ÿ“‹ FINAL TEST RESULTS") + print('='*70) + + for test_name, result in results: + icon = "โœ…" if result == "PASS" else "โŒ" + print(f"{icon} {test_name}: {result}") + + pass_count = sum(1 for _, result in results if result == "PASS") + total_count = len(results) + + print(f"\n๐Ÿ“Š Overall Result: {pass_count}/{total_count} tests passed ({pass_count/total_count*100:.1f}%)") + print(f"๐Ÿ• Test completed: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + if pass_count == total_count: + print("\n๐ŸŽ‰ ALL TESTS PASSED! 15-year financial data fixes are working correctly.") + else: + print(f"\nโš ๏ธ {total_count - pass_count} tests failed. Some issues may remain.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_financial.py b/tests/test_financial.py new file mode 100644 index 0000000..41f8b7c --- /dev/null +++ b/tests/test_financial.py @@ -0,0 +1,188 @@ +""" +Test financial data endpoints +""" + +import pytest +from fastapi.testclient import TestClient +from datetime import datetime, timedelta + +def test_get_financial_data_post_valid_request(client: TestClient, sample_financial_data): + """Test POST financial data endpoint with valid request""" + response = client.post("/api/v1/financial/data", json=sample_financial_data) + + # Should succeed or return specific error + assert response.status_code in [200, 404, 500] + + if response.status_code == 200: + data = response.json() + + # Check response structure + assert "company" in data + assert "financial_data" in data + assert "metadata" in data + + # Check company info + company = data["company"] + assert "ticker" in company + assert "name" in company + + # Check metadata + metadata = data["metadata"] + assert "request_id" in metadata + assert "last_updated" in metadata + +def test_get_financial_data_post_invalid_dates(client: TestClient): + """Test POST endpoint with invalid date range""" + invalid_data = { + "ticker": "AAPL", + "start_date": "2023-12-31T00:00:00", + "end_date": "2023-01-01T00:00:00", # End before start + "period_type": "quarterly" + } + + response = client.post("/api/v1/financial/data", json=invalid_data) + + assert response.status_code == 400 + data = response.json() + assert "detail" in data + assert "error_type" in data["detail"] + assert data["detail"]["error_type"] == "VALIDATION_ERROR" + +def test_get_financial_data_post_future_dates(client: TestClient): + """Test POST endpoint with future dates""" + future_date = datetime.now() + timedelta(days=365) + future_data = { + "ticker": "AAPL", + "start_date": future_date.isoformat(), + "end_date": (future_date + timedelta(days=30)).isoformat(), + "period_type": "quarterly" + } + + response = client.post("/api/v1/financial/data", json=future_data) + + assert response.status_code == 400 + data = response.json() + assert "detail" in data + assert "error_type" in data["detail"] + assert data["detail"]["error_type"] == "INVALID_PERIOD" + +def test_get_financial_data_post_old_dates(client: TestClient): + """Test POST endpoint with dates before SEC data availability""" + old_data = { + "ticker": "AAPL", + "start_date": "1990-01-01T00:00:00", + "end_date": "1990-12-31T00:00:00", + "period_type": "quarterly" + } + + response = client.post("/api/v1/financial/data", json=old_data) + + assert response.status_code == 400 + data = response.json() + assert "detail" in data + assert "error_type" in data["detail"] + assert data["detail"]["error_type"] == "INVALID_PERIOD" + +def test_get_financial_data_get_valid_request(client: TestClient): + """Test GET financial data endpoint with valid parameters""" + params = { + "start_date": "2023-01-01T00:00:00", + "end_date": "2023-12-31T23:59:59", + "period_type": "quarterly", + "include_metrics": True, + "force_refresh": False + } + + response = client.get("/api/v1/financial/data/AAPL", params=params) + + # Should succeed or return specific error + assert response.status_code in [200, 404, 500] + + if response.status_code == 200: + data = response.json() + assert "company" in data + assert "financial_data" in data + +def test_get_financial_data_get_missing_params(client: TestClient): + """Test GET endpoint with missing required parameters""" + # Missing start_date and end_date + response = client.get("/api/v1/financial/data/AAPL") + + assert response.status_code == 422 # Validation error + +def test_get_financial_data_invalid_ticker(client: TestClient, sample_financial_data): + """Test with invalid ticker""" + invalid_data = sample_financial_data.copy() + invalid_data["ticker"] = "INVALID_TICKER_123" + + response = client.post("/api/v1/financial/data", json=invalid_data) + + # Should return 404 or 500 depending on implementation + assert response.status_code in [404, 500] + +def test_financial_data_request_validation(client: TestClient): + """Test request validation""" + # Empty ticker + invalid_requests = [ + { + "ticker": "", + "start_date": "2023-01-01T00:00:00", + "end_date": "2023-12-31T00:00:00" + }, + { + "ticker": "A" * 20, # Too long + "start_date": "2023-01-01T00:00:00", + "end_date": "2023-12-31T00:00:00" + }, + { + # Missing required fields + "ticker": "AAPL" + } + ] + + for invalid_request in invalid_requests: + response = client.post("/api/v1/financial/data", json=invalid_request) + assert response.status_code == 422 + +def test_financial_data_period_type_validation(client: TestClient): + """Test period type validation""" + valid_periods = ["quarterly", "annual", "all"] + + for period in valid_periods: + data = { + "ticker": "AAPL", + "start_date": "2023-01-01T00:00:00", + "end_date": "2023-12-31T00:00:00", + "period_type": period + } + + response = client.post("/api/v1/financial/data", json=data) + # Should not fail on validation + assert response.status_code != 422 + +def test_financial_data_optional_parameters(client: TestClient): + """Test optional parameters""" + # Test with all optional parameters + data = { + "ticker": "AAPL", + "start_date": "2023-01-01T00:00:00", + "end_date": "2023-12-31T00:00:00", + "period_type": "quarterly", + "include_metrics": False, + "force_refresh": True + } + + response = client.post("/api/v1/financial/data", json=data) + # Should not fail on validation + assert response.status_code != 422 + + # Test with minimal parameters + minimal_data = { + "ticker": "AAPL", + "start_date": "2023-01-01T00:00:00", + "end_date": "2023-12-31T00:00:00" + } + + response = client.post("/api/v1/financial/data", json=minimal_data) + # Should not fail on validation + assert response.status_code != 422 \ No newline at end of file diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..1157f89 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,45 @@ +""" +Test health endpoint +""" + +import pytest +from fastapi.testclient import TestClient + +def test_health_check(client: TestClient): + """Test health check endpoint""" + response = client.get("/api/v1/health") + + assert response.status_code == 200 + data = response.json() + + # Check required fields + assert "status" in data + assert "version" in data + assert "database" in data + assert "cache" in data + assert "sec_data_available" in data + assert "timestamp" in data + + # Version should match + assert data["version"] == "1.0.0" + + # Status should be string + assert isinstance(data["status"], str) + assert data["status"] in ["healthy", "degraded", "unhealthy"] + +def test_health_check_response_format(client: TestClient): + """Test health check response format""" + response = client.get("/api/v1/health") + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + + data = response.json() + + # Check data types + assert isinstance(data["status"], str) + assert isinstance(data["version"], str) + assert isinstance(data["database"], str) + assert isinstance(data["cache"], str) + assert isinstance(data["sec_data_available"], bool) + assert isinstance(data["timestamp"], str) \ No newline at end of file diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..59c7100 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,209 @@ +""" +Integration tests for complete API workflows +""" + +import pytest +from fastapi.testclient import TestClient +from datetime import datetime, timedelta + +def test_complete_api_workflow(client: TestClient): + """Test complete API workflow from health check to data retrieval""" + + # 1. Health check + health_response = client.get("/api/v1/health") + assert health_response.status_code == 200 + + # 2. Get data catalog + catalog_response = client.get("/api/v1/metadata/catalog") + assert catalog_response.status_code == 200 + catalog_data = catalog_response.json() + assert "categories" in catalog_data + + # 3. Try to get financial data + financial_request = { + "ticker": "AAPL", + "start_date": "2023-01-01T00:00:00", + "end_date": "2023-03-31T23:59:59", + "period_type": "quarterly", + "include_metrics": True + } + + financial_response = client.post("/api/v1/financial/data", json=financial_request) + # Should succeed or return specific error (not validation error) + assert financial_response.status_code in [200, 404, 500] + + # 4. Try OHLCV endpoint (should be not implemented) + ohlcv_request = { + "ticker": "AAPL", + "start_date": "2023-01-01T00:00:00", + "end_date": "2023-03-31T23:59:59", + "interval": "1d" + } + + ohlcv_response = client.post("/api/v1/market/ohlcv", json=ohlcv_request) + assert ohlcv_response.status_code in [200, 404, 501] + +def test_api_error_handling_consistency(client: TestClient): + """Test that error responses are consistent across endpoints""" + + # Test validation errors + validation_errors = [] + + # Financial endpoint validation error + response = client.post("/api/v1/financial/data", json={"ticker": ""}) + if response.status_code == 422: + validation_errors.append(response.json()) + + # OHLCV endpoint validation error + response = client.post("/api/v1/market/ohlcv", json={"ticker": ""}) + if response.status_code == 422: + validation_errors.append(response.json()) + + # Check validation errors have consistent structure + for error in validation_errors: + assert "detail" in error + # FastAPI validation errors have specific structure + if isinstance(error["detail"], list): + for item in error["detail"]: + assert "type" in item + assert "msg" in item + +def test_date_range_validation_consistency(client: TestClient): + """Test that date range validation is consistent across endpoints""" + + # Invalid date range (end before start) + invalid_dates = { + "start_date": "2023-12-31T00:00:00", + "end_date": "2023-01-01T00:00:00" + } + + # Test financial endpoint + financial_request = { + "ticker": "AAPL", + **invalid_dates, + "period_type": "quarterly" + } + + response = client.post("/api/v1/financial/data", json=financial_request) + assert response.status_code == 400 + + # Test OHLCV endpoint + ohlcv_request = { + "ticker": "AAPL", + **invalid_dates, + "interval": "1d" + } + + response = client.post("/api/v1/market/ohlcv", json=ohlcv_request) + assert response.status_code == 400 + +def test_ticker_validation_consistency(client: TestClient): + """Test that ticker validation is consistent across endpoints""" + + valid_date_range = { + "start_date": "2023-01-01T00:00:00", + "end_date": "2023-03-31T23:59:59" + } + + # Test empty ticker + financial_request = { + "ticker": "", + **valid_date_range, + "period_type": "quarterly" + } + + response = client.post("/api/v1/financial/data", json=financial_request) + assert response.status_code == 422 + + ohlcv_request = { + "ticker": "", + **valid_date_range, + "interval": "1d" + } + + response = client.post("/api/v1/market/ohlcv", json=ohlcv_request) + assert response.status_code == 422 + +def test_response_structure_consistency(client: TestClient): + """Test that successful responses have consistent structure""" + + # Get catalog (should always work) + catalog_response = client.get("/api/v1/metadata/catalog") + assert catalog_response.status_code == 200 + catalog_data = catalog_response.json() + + # Check common response patterns + assert isinstance(catalog_data, dict) + assert "last_updated" in catalog_data + + # Health check + health_response = client.get("/api/v1/health") + assert health_response.status_code == 200 + health_data = health_response.json() + + assert isinstance(health_data, dict) + assert "timestamp" in health_data + assert "status" in health_data + +def test_content_type_headers(client: TestClient): + """Test that all endpoints return proper content-type headers""" + + endpoints = [ + "/api/v1/health", + "/api/v1/metadata/catalog" + ] + + for endpoint in endpoints: + response = client.get(endpoint) + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + +def test_cors_and_security_headers(client: TestClient): + """Test CORS and basic security considerations""" + + # Test that endpoints don't expose sensitive information in headers + response = client.get("/api/v1/health") + assert response.status_code == 200 + + # Should not expose server information + assert "server" not in response.headers or "FastAPI" not in response.headers.get("server", "") + +def test_openapi_documentation(client: TestClient): + """Test that OpenAPI documentation is accessible""" + + # OpenAPI JSON should be accessible + response = client.get("/api/v1/openapi.json") + assert response.status_code == 200 + + openapi_spec = response.json() + assert "openapi" in openapi_spec + assert "info" in openapi_spec + assert "paths" in openapi_spec + + # Check that main endpoints are documented + paths = openapi_spec["paths"] + assert "/api/v1/health" in paths + assert "/api/v1/financial/data" in paths + assert "/api/v1/metadata/catalog" in paths + +def test_rate_limiting_headers(client: TestClient): + """Test for rate limiting indicators (if implemented)""" + + response = client.get("/api/v1/health") + assert response.status_code == 200 + + # Rate limiting headers are optional but good to check + # X-RateLimit-* headers would be present if rate limiting is implemented + # This test just ensures we don't break if they're added later + +def test_api_versioning(client: TestClient): + """Test API versioning is properly implemented""" + + # All endpoints should be under /api/v1 + response = client.get("/api/v1/health") + assert response.status_code == 200 + + # Root should redirect to docs + response = client.get("/", follow_redirects=False) + assert response.status_code == 307 # Redirect + assert "api/v1" in response.headers.get("location", "") \ No newline at end of file diff --git a/tests/test_migration.py b/tests/test_migration.py new file mode 100644 index 0000000..e004bc9 --- /dev/null +++ b/tests/test_migration.py @@ -0,0 +1,160 @@ +""" +Test migration endpoints +""" + +import pytest +from fastapi.testclient import TestClient + +def test_migrate_data_without_api_key(client: TestClient): + """Test migration endpoint without API key""" + migration_request = { + "source_url": "http://test-source.com", + "api_key": "test-key", + "tickers": ["AAPL", "MSFT"] + } + + response = client.post("/api/v1/admin/migrate", json=migration_request) + + assert response.status_code == 401 + data = response.json() + assert "detail" in data + assert "error_type" in data["detail"] + assert data["detail"]["error_type"] == "AUTHENTICATION_ERROR" + +def test_migrate_data_with_invalid_api_key(client: TestClient): + """Test migration endpoint with invalid API key""" + migration_request = { + "source_url": "http://test-source.com", + "api_key": "test-key", + "tickers": ["AAPL", "MSFT"] + } + + headers = {"X-API-Key": "invalid-key"} + response = client.post("/api/v1/admin/migrate", json=migration_request, headers=headers) + + assert response.status_code == 401 + data = response.json() + assert "detail" in data + assert "error_type" in data["detail"] + assert data["detail"]["error_type"] == "AUTHENTICATION_ERROR" + +def test_migrate_data_with_valid_api_key(client: TestClient, valid_migration_key): + """Test migration endpoint with valid API key""" + migration_request = { + "source_url": "http://test-source.com", + "api_key": "test-key", + "tickers": ["AAPL"] + } + + headers = {"X-API-Key": valid_migration_key} + response = client.post("/api/v1/admin/migrate", json=migration_request, headers=headers) + + # Should proceed with migration attempt (will likely fail due to invalid source URL) + assert response.status_code in [200, 500] + + if response.status_code == 200: + data = response.json() + # Check response structure + assert "status" in data + assert "total_records" in data + assert "migrated_records" in data + assert "failed_records" in data + assert "errors" in data + assert "duration_seconds" in data + +def test_migration_request_validation(client: TestClient, valid_migration_key): + """Test migration request validation""" + headers = {"X-API-Key": valid_migration_key} + + # Missing required fields + invalid_requests = [ + { + # Missing source_url + "api_key": "test-key" + }, + { + # Missing api_key + "source_url": "http://test.com" + }, + { + # Invalid URL format + "source_url": "not-a-url", + "api_key": "test-key" + } + ] + + for invalid_request in invalid_requests: + response = client.post("/api/v1/admin/migrate", json=invalid_request, headers=headers) + assert response.status_code == 422 + +def test_migration_with_optional_parameters(client: TestClient, valid_migration_key): + """Test migration with optional parameters""" + migration_request = { + "source_url": "http://test-source.com", + "api_key": "test-key", + "tickers": ["AAPL", "MSFT"], + "start_date": "2023-01-01T00:00:00", + "end_date": "2023-12-31T23:59:59" + } + + headers = {"X-API-Key": valid_migration_key} + response = client.post("/api/v1/admin/migrate", json=migration_request, headers=headers) + + # Should not fail on validation + assert response.status_code != 422 + +def test_export_data_without_api_key(client: TestClient): + """Test export endpoint without API key""" + response = client.get("/api/v1/admin/migration/export/AAPL") + + assert response.status_code == 401 + data = response.json() + assert "detail" in data + assert "error_type" in data["detail"] + assert data["detail"]["error_type"] == "AUTHENTICATION_ERROR" + +def test_export_data_with_valid_api_key(client: TestClient, valid_migration_key): + """Test export endpoint with valid API key""" + headers = {"X-API-Key": valid_migration_key} + response = client.get("/api/v1/admin/migration/export/AAPL", headers=headers) + + assert response.status_code == 200 + data = response.json() + + # Check response structure + assert "ticker" in data + assert data["ticker"] == "AAPL" + assert "message" in data + +def test_export_data_with_parameters(client: TestClient, valid_migration_key): + """Test export endpoint with query parameters""" + params = { + "start_date": "2023-01-01T00:00:00", + "end_date": "2023-12-31T23:59:59" + } + + headers = {"X-API-Key": valid_migration_key} + response = client.get("/api/v1/admin/migration/export/AAPL", params=params, headers=headers) + + assert response.status_code == 200 + +def test_migration_disabled_setting(client: TestClient, monkeypatch): + """Test migration endpoints when migration is disabled""" + # Mock settings to disable migration + from app.core.config import settings + monkeypatch.setattr(settings, "ALLOW_MIGRATION", False) + + migration_request = { + "source_url": "http://test-source.com", + "api_key": "test-key" + } + + headers = {"X-API-Key": "any-key"} + response = client.post("/api/v1/admin/migrate", json=migration_request, headers=headers) + + assert response.status_code == 403 + data = response.json() + assert "detail" in data + assert "error_type" in data["detail"] + assert data["detail"]["error_type"] == "AUTHENTICATION_ERROR" + assert "disabled" in data["detail"]["message"] \ No newline at end of file diff --git a/tests/test_mock_data.py b/tests/test_mock_data.py new file mode 100644 index 0000000..21b31f6 --- /dev/null +++ b/tests/test_mock_data.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +""" +Mock ๋ฐ์ดํ„ฐ ์ƒ์„ฑ ํ…Œ์ŠคํŠธ +""" + +from mock_test import MockStockOracleAnalyzer +import json +from datetime import datetime, timedelta + +# Mock analyzer ์ƒ์„ฑ +analyzer = MockStockOracleAnalyzer() + +# ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ +ticker = "AAPL" +years = 1 +data = analyzer.get_company_data(ticker, years=years) + +print("Company Info:", data.get('company_info')) +print("\n") + +# Financial data structure +financial_data = data.get('financial_data', {}) +print("Financial data keys:", financial_data.keys()) + +# Check income statement +income_statement = financial_data.get('income_statement', {}) +print("\nIncome statement keys:", income_statement.keys()) + +# Check revenue data +revenue_data = income_statement.get('revenue', []) +print(f"\nRevenue data points: {len(revenue_data)}") +if revenue_data: + print("First revenue data point:", revenue_data[0]) + print("Last revenue data point:", revenue_data[-1]) + +# Date filtering test +start_date = datetime(2020, 1, 1) +end_date = datetime(2020, 6, 1) + +filtered_revenue = [] +for item in revenue_data: + item_date = item['date'] + if hasattr(item_date, 'to_pydatetime'): + item_date = item_date.to_pydatetime() + + if start_date <= item_date <= end_date: + filtered_revenue.append(item) + +print(f"\nFiltered revenue data points (2020-01-01 to 2020-06-01): {len(filtered_revenue)}") +for item in filtered_revenue: + print(f" Date: {item['date']}, Value: ${item['value']:,.0f}") \ No newline at end of file diff --git a/tests/test_ohlcv.py b/tests/test_ohlcv.py new file mode 100644 index 0000000..f57463d --- /dev/null +++ b/tests/test_ohlcv.py @@ -0,0 +1,125 @@ +""" +Test OHLCV endpoints +""" + +import pytest +from fastapi.testclient import TestClient + +def test_get_ohlcv_data_post(client: TestClient, sample_ohlcv_data): + """Test POST OHLCV endpoint""" + response = client.post("/api/v1/market/ohlcv", json=sample_ohlcv_data) + + # Should return 501 (not implemented) or 200/404 + assert response.status_code in [200, 404, 501] + + if response.status_code == 501: + data = response.json() + assert "detail" in data + assert "error_type" in data["detail"] + assert data["detail"]["error_type"] == "DATA_NOT_FOUND" + assert "not yet implemented" in data["detail"]["message"] + +def test_get_ohlcv_data_get(client: TestClient): + """Test GET OHLCV endpoint""" + params = { + "start_date": "2023-01-01T00:00:00", + "end_date": "2023-12-31T23:59:59", + "interval": "1d" + } + + response = client.get("/api/v1/market/ohlcv/AAPL", params=params) + + # Should return 501 (not implemented) or 200/404 + assert response.status_code in [200, 404, 501] + +def test_ohlcv_invalid_dates(client: TestClient): + """Test OHLCV with invalid dates""" + invalid_data = { + "ticker": "AAPL", + "start_date": "2023-12-31T00:00:00", + "end_date": "2023-01-01T00:00:00", # End before start + "interval": "1d" + } + + response = client.post("/api/v1/market/ohlcv", json=invalid_data) + + assert response.status_code == 400 + data = response.json() + assert "detail" in data + assert "error_type" in data["detail"] + assert data["detail"]["error_type"] == "VALIDATION_ERROR" + +def test_ohlcv_invalid_interval(client: TestClient): + """Test OHLCV with invalid interval""" + invalid_data = { + "ticker": "AAPL", + "start_date": "2023-01-01T00:00:00", + "end_date": "2023-12-31T00:00:00", + "interval": "5min" # Invalid interval + } + + response = client.post("/api/v1/market/ohlcv", json=invalid_data) + + assert response.status_code == 422 # Validation error + +def test_ohlcv_valid_intervals(client: TestClient): + """Test OHLCV with all valid intervals""" + valid_intervals = ["1d", "1w", "1m"] + + for interval in valid_intervals: + data = { + "ticker": "AAPL", + "start_date": "2023-01-01T00:00:00", + "end_date": "2023-12-31T00:00:00", + "interval": interval + } + + response = client.post("/api/v1/market/ohlcv", json=data) + # Should not fail on validation + assert response.status_code != 422 + +def test_ohlcv_request_validation(client: TestClient): + """Test OHLCV request validation""" + # Empty ticker + invalid_requests = [ + { + "ticker": "", + "start_date": "2023-01-01T00:00:00", + "end_date": "2023-12-31T00:00:00", + "interval": "1d" + }, + { + "ticker": "A" * 20, # Too long + "start_date": "2023-01-01T00:00:00", + "end_date": "2023-12-31T00:00:00", + "interval": "1d" + }, + { + # Missing required fields + "ticker": "AAPL", + "interval": "1d" + } + ] + + for invalid_request in invalid_requests: + response = client.post("/api/v1/market/ohlcv", json=invalid_request) + assert response.status_code == 422 + +def test_ohlcv_get_missing_params(client: TestClient): + """Test GET OHLCV endpoint with missing parameters""" + # Missing required parameters + response = client.get("/api/v1/market/ohlcv/AAPL") + + assert response.status_code == 422 + +def test_ohlcv_get_invalid_interval_param(client: TestClient): + """Test GET OHLCV endpoint with invalid interval parameter""" + params = { + "start_date": "2023-01-01T00:00:00", + "end_date": "2023-12-31T00:00:00", + "interval": "invalid" + } + + response = client.get("/api/v1/market/ohlcv/AAPL", params=params) + + assert response.status_code == 422 \ No newline at end of file diff --git a/tests/test_quick.py b/tests/test_quick.py new file mode 100755 index 0000000..478f203 --- /dev/null +++ b/tests/test_quick.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +""" +๋น ๋ฅธ ๊ธฐ๋Šฅ ํ…Œ์ŠคํŠธ - ํ•ต์‹ฌ ๊ธฐ๋Šฅ๋งŒ ๋น ๋ฅด๊ฒŒ ๊ฒ€์ฆ +""" + +import requests +import time +from datetime import datetime + +API_URL = "http://localhost:18001/api/v1" + +def test_api_health(): + """API ์„œ๋ฒ„ ์ƒํƒœ ํ™•์ธ""" + print("๐Ÿฅ API ์„œ๋ฒ„ ์ƒํƒœ ํ™•์ธ...") + try: + # Try docs endpoint instead of health + response = requests.get(f"{API_URL.replace('/api/v1', '')}/docs", timeout=5) + if response.status_code == 200: + print("โœ… API ์„œ๋ฒ„ ์ •์ƒ") + return True + else: + print(f"โŒ API ์„œ๋ฒ„ ์‘๋‹ต ์˜ค๋ฅ˜: {response.status_code}") + return False + except Exception as e: + print(f"โŒ API ์„œ๋ฒ„ ์—ฐ๊ฒฐ ์‹คํŒจ: {e}") + return False + +def test_financial_data_basic(): + """๊ธฐ๋ณธ ์žฌ๋ฌด ๋ฐ์ดํ„ฐ ํ…Œ์ŠคํŠธ""" + print("๐Ÿ’ฐ ๊ธฐ๋ณธ ์žฌ๋ฌด ๋ฐ์ดํ„ฐ ํ…Œ์ŠคํŠธ...") + try: + response = requests.post( + f"{API_URL}/financial/data", + json={ + "ticker": "AAPL", + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "period_type": "quarterly", + "include_metrics": True, + "force_refresh": False + }, + timeout=30 + ) + + if response.status_code == 200: + data = response.json() + financial_data = data.get('financial_data', []) + + if len(financial_data) > 0: + real_data_count = sum(1 for fd in financial_data if not fd.get('is_estimated', True)) + print(f"โœ… ์žฌ๋ฌด ๋ฐ์ดํ„ฐ ์ •์ƒ ({len(financial_data)}๊ฐœ ๋ถ„๊ธฐ, {real_data_count}๊ฐœ ์‹ค์ œ ๋ฐ์ดํ„ฐ)") + + # ์ตœ์‹  ๋ฐ์ดํ„ฐ ํ™•์ธ + latest = financial_data[-1] if financial_data else {} + revenue = latest.get('revenue', 0) + pe_ratio = latest.get('pe_ratio', 0) + + print(f" ๐Ÿ“Š ์ตœ์‹  ๋ถ„๊ธฐ: {latest.get('period_date', 'N/A')}") + print(f" ๐Ÿ’ต ๋งค์ถœ: ${revenue:,.0f}") + print(f" ๐Ÿ“ˆ P/E ๋น„์œจ: {pe_ratio:.2f}") + print(f" ๐Ÿ” ๋ฐ์ดํ„ฐ ์†Œ์Šค: {latest.get('data_source', 'Unknown')}") + + return True + else: + print("โŒ ์žฌ๋ฌด ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") + return False + else: + print(f"โŒ API ํ˜ธ์ถœ ์‹คํŒจ: {response.status_code}") + return False + + except Exception as e: + print(f"โŒ ์žฌ๋ฌด ๋ฐ์ดํ„ฐ ํ…Œ์ŠคํŠธ ์‹คํŒจ: {e}") + return False + +def test_price_data_basic(): + """๊ธฐ๋ณธ ์ฃผ๊ฐ€ ๋ฐ์ดํ„ฐ ํ…Œ์ŠคํŠธ""" + print("๐Ÿ“ˆ ๊ธฐ๋ณธ ์ฃผ๊ฐ€ ๋ฐ์ดํ„ฐ ํ…Œ์ŠคํŠธ...") + try: + response = requests.post( + f"{API_URL}/price/data", + json={ + "ticker": "AAPL", + "start_date": "2024-12-01", + "end_date": "2024-12-31", + "interval": "1d" + }, + timeout=30 + ) + + if response.status_code == 200: + data = response.json() + price_data = data.get('price_data', []) + + if len(price_data) > 0: + print(f"โœ… ์ฃผ๊ฐ€ ๋ฐ์ดํ„ฐ ์ •์ƒ ({len(price_data)}์ผ)") + + # ์ตœ์‹  ๊ฐ€๊ฒฉ ํ™•์ธ + latest = price_data[-1] if price_data else {} + close_price = latest.get('close', 0) + volume = latest.get('volume', 0) + + print(f" ๐Ÿ“… ์ตœ์‹  ๋‚ ์งœ: {latest.get('date', 'N/A')}") + print(f" ๐Ÿ’ฒ ์ข…๊ฐ€: ${close_price:.2f}") + print(f" ๐Ÿ“Š ๊ฑฐ๋ž˜๋Ÿ‰: {volume:,.0f}") + + return True + else: + print("โŒ ์ฃผ๊ฐ€ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") + return False + else: + print(f"โŒ API ํ˜ธ์ถœ ์‹คํŒจ: {response.status_code}") + return False + + except Exception as e: + print(f"โŒ ์ฃผ๊ฐ€ ๋ฐ์ดํ„ฐ ํ…Œ์ŠคํŠธ ์‹คํŒจ: {e}") + return False + +def main(): + """๋น ๋ฅธ ํ…Œ์ŠคํŠธ ์‹คํ–‰""" + print("โšก Stock Oracle ๋น ๋ฅธ ๊ธฐ๋Šฅ ํ…Œ์ŠคํŠธ") + print("=" * 50) + print(f"ํ…Œ์ŠคํŠธ ์‹œ๊ฐ„: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print() + + start_time = time.time() + + # ํ…Œ์ŠคํŠธ ์‹คํ–‰ + tests = [ + ("์„œ๋ฒ„ ์ƒํƒœ", test_api_health), + ("์žฌ๋ฌด ๋ฐ์ดํ„ฐ", test_financial_data_basic), + ("์ฃผ๊ฐ€ ๋ฐ์ดํ„ฐ", test_price_data_basic) + ] + + results = [] + for test_name, test_func in tests: + print(f"๐Ÿ” {test_name} ํ…Œ์ŠคํŠธ...") + result = test_func() + results.append((test_name, result)) + print() + + # ๊ฒฐ๊ณผ ์š”์•ฝ + end_time = time.time() + duration = end_time - start_time + + print("=" * 50) + print("๐Ÿ“Š ๋น ๋ฅธ ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ") + print("=" * 50) + + passed = sum(1 for _, result in results if result) + total = len(results) + + print(f"์ด ํ…Œ์ŠคํŠธ: {total}") + print(f"์„ฑ๊ณต: {passed} โœ…") + print(f"์‹คํŒจ: {total - passed} โŒ") + print(f"์‹คํ–‰ ์‹œ๊ฐ„: {duration:.1f}์ดˆ") + print() + + for test_name, result in results: + status = "โœ…" if result else "โŒ" + print(f"{status} {test_name}") + + if passed == total: + print("\n๐ŸŽ‰ ๋ชจ๋“  ๊ธฐ๋ณธ ๊ธฐ๋Šฅ์ด ์ •์ƒ ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค!") + return 0 + else: + print(f"\nโŒ {total - passed}๊ฐœ์˜ ํ…Œ์ŠคํŠธ๊ฐ€ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") + return 1 + +if __name__ == "__main__": + exit(main()) \ No newline at end of file diff --git a/tests/test_real_sec_vs_yfinance_plus.py b/tests/test_real_sec_vs_yfinance_plus.py new file mode 100644 index 0000000..4c6ae08 --- /dev/null +++ b/tests/test_real_sec_vs_yfinance_plus.py @@ -0,0 +1,225 @@ +""" +Test script to compare Stock Oracle real SEC data with yfinance_plus data +""" + +import requests +import yfinance_plus as yf +from datetime import datetime, timezone +import pandas as pd +import json +from typing import Dict, Any + +# API configuration +API_URL = "http://localhost:18001/api/v1" + +def get_yfinance_plus_data(ticker: str) -> Dict[str, Any]: + """Get financial data directly from yfinance_plus""" + print(f"\n=== Getting yfinance_plus data for {ticker} ===") + + # Create ticker object + stock = yf.Ticker(ticker) + + # Get basic info + info = stock.info + print(f"Company: {info.get('longName', 'N/A')}") + print(f"Sector: {info.get('sector', 'N/A')}") + print(f"Industry: {info.get('industry', 'N/A')}") + print(f"Market Cap: ${info.get('marketCap', 0):,.0f}") + print(f"Shares Outstanding: {info.get('sharesOutstanding', 0):,.0f}") + print(f"Current Price: ${info.get('currentPrice', info.get('regularMarketPrice', 0)):.2f}") + + # Get quarterly financial statements + quarterly_income = stock.quarterly_income_stmt + quarterly_balance = stock.quarterly_balance_sheet + quarterly_cashflow = stock.quarterly_cashflow + + print(f"\nQuarterly Income Statement shape: {quarterly_income.shape}") + print(f"Quarterly Balance Sheet shape: {quarterly_balance.shape}") + print(f"Quarterly Cash Flow shape: {quarterly_cashflow.shape}") + + # Show latest quarter data + if not quarterly_income.empty: + latest_quarter = quarterly_income.columns[0] + print(f"\n=== Latest Quarter: {latest_quarter} ===") + + # Key income statement items + revenue = quarterly_income.loc['Total Revenue', latest_quarter] if 'Total Revenue' in quarterly_income.index else None + net_income = quarterly_income.loc['Net Income', latest_quarter] if 'Net Income' in quarterly_income.index else None + eps = quarterly_income.loc['Diluted EPS', latest_quarter] if 'Diluted EPS' in quarterly_income.index else None + + print(f"Revenue: ${revenue:,.0f}") + print(f"Net Income: ${net_income:,.0f}") + print(f"EPS: ${eps:.2f}") + + # Balance sheet items + if not quarterly_balance.empty and latest_quarter in quarterly_balance.columns: + total_assets = quarterly_balance.loc['Total Assets', latest_quarter] if 'Total Assets' in quarterly_balance.index else None + total_equity = quarterly_balance.loc['Total Equity Gross Minority Interest', latest_quarter] if 'Total Equity Gross Minority Interest' in quarterly_balance.index else None + total_debt = quarterly_balance.loc['Total Debt', latest_quarter] if 'Total Debt' in quarterly_balance.index else None + + print(f"Total Assets: ${total_assets:,.0f}") + print(f"Total Equity: ${total_equity:,.0f}") + print(f"Total Debt: ${total_debt:,.0f}") + + return { + 'info': info, + 'quarterly_income': quarterly_income.to_dict() if not quarterly_income.empty else {}, + 'quarterly_balance': quarterly_balance.to_dict() if not quarterly_balance.empty else {}, + 'quarterly_cashflow': quarterly_cashflow.to_dict() if not quarterly_cashflow.empty else {}, + } + +def get_stock_oracle_real_data(ticker: str, start_date: str, end_date: str) -> Dict[str, Any]: + """Get real financial data from Stock Oracle API""" + print(f"\n=== Getting Stock Oracle REAL SEC data for {ticker} ===") + + # Request data from API with force refresh to get real data + response = requests.post( + f"{API_URL}/financial/data", + json={ + "ticker": ticker, + "start_date": start_date, + "end_date": end_date, + "period_type": "all", + "include_metrics": True, + "force_refresh": True # Force refresh to fetch real data + } + ) + + if response.status_code == 200: + data = response.json() + print(f"Company: {data['company']['name']}") + print(f"Sector: {data['company']['sector']}") + print(f"Industry: {data['company']['industry']}") + print(f"Number of periods: {len(data['financial_data'])}") + + # Check if we have real data (not estimated) + real_data_count = sum(1 for fd in data['financial_data'] if not fd.get('is_estimated', True)) + print(f"Real data periods: {real_data_count}") + print(f"Estimated data periods: {len(data['financial_data']) - real_data_count}") + + return data + else: + print(f"Error: {response.status_code} - {response.text}") + return None + +def compare_real_financial_data(yfinance_data: Dict, oracle_data: Dict, ticker: str): + """Compare real financial data between yfinance_plus and Stock Oracle""" + print(f"\n=== REAL DATA COMPARISON FOR {ticker} ===") + + if not oracle_data or 'financial_data' not in oracle_data: + print("No Stock Oracle data to compare") + return + + # Find real (non-estimated) data in Stock Oracle + real_oracle_data = [fd for fd in oracle_data['financial_data'] if not fd.get('is_estimated', True)] + + if not real_oracle_data: + print("No real (non-estimated) data found in Stock Oracle") + return + + print(f"Found {len(real_oracle_data)} real periods in Stock Oracle") + + # Get the latest real period from Stock Oracle + latest_oracle = real_oracle_data[-1] if real_oracle_data else None + + if not latest_oracle: + print("No real financial data in Stock Oracle") + return + + print(f"\nStock Oracle Latest REAL Period: {latest_oracle['period_date']}") + print(f"Data Source: {latest_oracle.get('data_source', 'Unknown')}") + print(f"Is Estimated: {latest_oracle.get('is_estimated', 'Unknown')}") + + # Compare with yfinance_plus + if 'quarterly_income' in yfinance_data and yfinance_data['quarterly_income']: + quarters = list(yfinance_data['quarterly_income'].keys()) + if quarters: + latest_quarter = quarters[0] + print(f"YFinance_plus Latest Quarter: {latest_quarter}") + + yf_data = yfinance_data['quarterly_income'][latest_quarter] + + # Compare key metrics + print(f"\n--- REVENUE COMPARISON ---") + oracle_revenue = latest_oracle.get('revenue', 0) + yf_revenue = yf_data.get('Total Revenue', 0) + print(f"Stock Oracle Revenue: ${oracle_revenue:,.2f}") + print(f"YFinance_plus Revenue: ${yf_revenue:,.2f}") + if oracle_revenue > 0 and yf_revenue > 0: + diff_pct = ((oracle_revenue - yf_revenue) / yf_revenue) * 100 + print(f"Difference: {diff_pct:.2f}%") + + print(f"\n--- NET INCOME COMPARISON ---") + oracle_net_income = latest_oracle.get('net_income', 0) + yf_net_income = yf_data.get('Net Income', 0) + print(f"Stock Oracle Net Income: ${oracle_net_income:,.2f}") + print(f"YFinance_plus Net Income: ${yf_net_income:,.2f}") + if oracle_net_income > 0 and yf_net_income > 0: + diff_pct = ((oracle_net_income - yf_net_income) / yf_net_income) * 100 + print(f"Difference: {diff_pct:.2f}%") + + print(f"\n--- EPS COMPARISON ---") + oracle_eps = latest_oracle.get('eps', 0) + yf_eps = yf_data.get('Diluted EPS', 0) + print(f"Stock Oracle EPS: ${oracle_eps:.2f}") + print(f"YFinance_plus EPS: ${yf_eps:.2f}") + if oracle_eps > 0 and yf_eps > 0: + diff_pct = ((oracle_eps - yf_eps) / yf_eps) * 100 + print(f"Difference: {diff_pct:.2f}%") + + print(f"\n--- SHARES OUTSTANDING COMPARISON ---") + oracle_shares = latest_oracle.get('shares_outstanding', 0) + yf_shares = yfinance_data['info'].get('sharesOutstanding', 0) + print(f"Stock Oracle Shares: {oracle_shares:,.0f}") + print(f"YFinance_plus Shares: {yf_shares:,.0f}") + if oracle_shares > 0 and yf_shares > 0: + diff_pct = ((oracle_shares - yf_shares) / yf_shares) * 100 + print(f"Difference: {diff_pct:.2f}%") + + print(f"\n--- MARKET CAP COMPARISON ---") + oracle_market_cap = latest_oracle.get('market_cap', 0) + yf_market_cap = yfinance_data['info'].get('marketCap', 0) + print(f"Stock Oracle Market Cap: ${oracle_market_cap:,.2f}") + print(f"YFinance_plus Market Cap: ${yf_market_cap:,.2f}") + if oracle_market_cap > 0 and yf_market_cap > 0: + diff_pct = ((oracle_market_cap - yf_market_cap) / yf_market_cap) * 100 + print(f"Difference: {diff_pct:.2f}%") + + print(f"\n--- P/E RATIO COMPARISON ---") + oracle_pe = latest_oracle.get('pe_ratio', 0) + yf_pe = yfinance_data['info'].get('trailingPE', 0) + print(f"Stock Oracle P/E: {oracle_pe:.2f}") + print(f"YFinance_plus P/E: {yf_pe:.2f}") + if oracle_pe > 0 and yf_pe > 0: + diff_pct = ((oracle_pe - yf_pe) / yf_pe) * 100 + print(f"Difference: {diff_pct:.2f}%") + +def main(): + """Main comparison function""" + # Test with Apple + ticker = "AAPL" + start_date = "2024-01-01" + end_date = "2024-12-31" + + print(f"Comparing REAL financial data for {ticker}") + print("=" * 80) + + # Get data from both sources + yfinance_data = get_yfinance_plus_data(ticker) + oracle_data = get_stock_oracle_real_data(ticker, start_date, end_date) + + # Compare the data + compare_real_financial_data(yfinance_data, oracle_data, ticker) + + # Test with Microsoft + print("\n" + "=" * 80) + ticker = "MSFT" + print(f"\nComparing REAL financial data for {ticker}") + print("=" * 80) + + yfinance_data = get_yfinance_plus_data(ticker) + oracle_data = get_stock_oracle_real_data(ticker, start_date, end_date) + compare_real_financial_data(yfinance_data, oracle_data, ticker) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_sec_edgar_15year.py b/tests/test_sec_edgar_15year.py new file mode 100644 index 0000000..4d7682c --- /dev/null +++ b/tests/test_sec_edgar_15year.py @@ -0,0 +1,258 @@ +""" +Comprehensive test for 15-year SEC EDGAR financial data fetching +Tests the new SEC-only implementation without yfinance_plus via API +""" + +import requests +import asyncio +from datetime import datetime, timezone, timedelta +import time +import json + + +class TestSECEdgar15Year: + """Test 15-year SEC EDGAR financial data fetching via API""" + + def setup_method(self): + """Setup test environment""" + self.api_url = "http://localhost:18001/api/v1" + + def test_sec_only_financial_data_aapl(self): + """Test that AAPL financial data comes from SEC only""" + print(f"\n=== Testing SEC EDGAR 15-year data for AAPL ===") + + # Test 15-year range + end_date = datetime.now() + start_date = end_date - timedelta(days=15*365) + + print(f"Date range: {start_date.date()} to {end_date.date()}") + + request_data = { + "ticker": "AAPL", + "start_date": start_date.strftime('%Y-%m-%d'), + "end_date": end_date.strftime('%Y-%m-%d'), + "period_type": "all", + "include_metrics": True, + "force_refresh": True + } + + try: + print("Requesting financial data...") + start_time = time.time() + + response = requests.post( + f"{self.api_url}/financial/data", + json=request_data, + timeout=120 + ) + + end_time = time.time() + duration = end_time - start_time + + assert response.status_code == 200, f"API request failed: {response.status_code}" + + data = response.json() + financial_data = data.get('financial_data', []) + print(f"Found {len(financial_data)} financial records in {duration:.1f}s") + + # Verify data source is SEC + sec_records = [ + record for record in financial_data + if record.get('data_source') == 'SEC_EDGAR' + ] + print(f"SEC EDGAR records: {len(sec_records)}") + + # Verify we have historical data (should go back multiple years) + if financial_data: + dates = [datetime.fromisoformat(record['period_date'].replace('Z', '+00:00')) for record in financial_data] + dates.sort() + oldest_date = dates[0] + newest_date = dates[-1] + + print(f"Date range in data: {oldest_date.date()} to {newest_date.date()}") + + years_span = (newest_date - oldest_date).days / 365.25 + print(f"Years span: {years_span:.1f} years") + + # Should have at least 3 years of data + assert years_span >= 3, f"Expected at least 3 years, got {years_span:.1f}" + + # Verify data quality + for i, record in enumerate(financial_data[:3]): + period_date = record['period_date'] + print(f"\nRecord {i+1} for {period_date[:10]}:") + print(f" Revenue: ${record.get('revenue', 0):,.0f}") + print(f" Net Income: ${record.get('net_income', 0):,.0f}") + print(f" Total Assets: ${record.get('total_assets', 0):,.0f}") + print(f" Data Source: {record.get('data_source')}") + print(f" Is Estimated: {record.get('is_estimated')}") + + # Verify it's real SEC data + assert record.get('data_source') == 'SEC_EDGAR' + assert record.get('is_estimated') == False + + assert len(financial_data) > 0, "Should have financial data from SEC" + print("โœ… Test passed: SEC EDGAR data retrieved successfully") + + except Exception as e: + print(f"โŒ Test failed: {e}") + raise + + def test_multiple_tickers_sec_data(self): + """Test SEC data fetching for multiple tickers""" + print(f"\n=== Testing Multiple Tickers SEC Data ===") + + tickers = ["AAPL", "MSFT"] + end_date = datetime.now() + start_date = end_date - timedelta(days=5*365) # 5 years + + for ticker in tickers: + print(f"\nTesting {ticker}...") + + request_data = { + "ticker": ticker, + "start_date": start_date.strftime('%Y-%m-%d'), + "end_date": end_date.strftime('%Y-%m-%d'), + "period_type": "all", + "include_metrics": True, + "force_refresh": False + } + + try: + response = requests.post( + f"{self.api_url}/financial/data", + json=request_data, + timeout=60 + ) + + if response.status_code == 200: + data = response.json() + financial_data = data.get('financial_data', []) + print(f" Records found: {len(financial_data)}") + + if financial_data: + # Show latest record + latest = max(financial_data, key=lambda x: x['period_date']) + print(f" Latest period: {latest['period_date'][:10]}") + print(f" Revenue: ${latest.get('revenue', 0):,.0f}") + print(f" Data source: {latest.get('data_source')}") + + # Verify all are SEC data + sec_count = sum(1 for r in financial_data if r.get('data_source') == 'SEC_EDGAR') + print(f" SEC records: {sec_count}/{len(financial_data)}") + + assert sec_count == len(financial_data), f"Expected all SEC data, got {sec_count}/{len(financial_data)}" + else: + print(f" โŒ Failed: {response.status_code}") + + except Exception as e: + print(f" โŒ Error: {e}") + + def test_no_yfinance_plus_dependency(self): + """Verify yfinance_plus is not used for financial data""" + print(f"\n=== Testing No yfinance_plus Dependency ===") + + # Test that yfinance_plus is not used + try: + import yfinance_plus + print("WARNING: yfinance_plus is still available in environment") + except ImportError: + print("โœ“ yfinance_plus correctly not available") + + # Test financial data request to ensure SEC is used + end_date = datetime.now() + start_date = end_date - timedelta(days=365) + + request_data = { + "ticker": "AAPL", + "start_date": start_date.strftime('%Y-%m-%d'), + "end_date": end_date.strftime('%Y-%m-%d'), + "period_type": "quarterly", + "include_metrics": True, + "force_refresh": False + } + + try: + response = requests.post( + f"{self.api_url}/financial/data", + json=request_data, + timeout=60 + ) + + assert response.status_code == 200, f"API request failed: {response.status_code}" + + data = response.json() + financial_data = data.get('financial_data', []) + + # All financial data should be from SEC + for record in financial_data: + assert record.get('data_source') == 'SEC_EDGAR', f"Expected SEC_EDGAR, got {record.get('data_source')}" + assert record.get('is_estimated') == False, "Should not be estimated data" + + print(f"โœ“ All {len(financial_data)} financial records are from SEC EDGAR") + + except Exception as e: + print(f"โŒ Error testing financial data: {e}") + raise + + def test_price_data_separation(self): + """Test that price data still works with yfinance""" + print(f"\n=== Testing Price Data Separation ===") + + end_date = datetime.now() + start_date = end_date - timedelta(days=30) # 30 days + + request_data = { + "ticker": "AAPL", + "start_date": start_date.strftime('%Y-%m-%d'), + "end_date": end_date.strftime('%Y-%m-%d'), + "interval": "1d" + } + + try: + response = requests.post( + f"{self.api_url}/financial/price-data", + json=request_data, + timeout=60 + ) + + if response.status_code == 200: + data = response.json() + price_data = data.get('price_data', []) + print(f"Price records found: {len(price_data)}") + + if price_data: + latest_price = max(price_data, key=lambda x: x['date']) + print(f"Latest price date: {latest_price['date'][:10]}") + print(f"Latest close: ${latest_price.get('close', 0):.2f}") + print(f"Price data source: {latest_price.get('data_source')}") + + # Price data should be from Yahoo Finance + yahoo_count = sum(1 for p in price_data if p.get('data_source') == 'YAHOO_FINANCE') + print(f"Yahoo Finance price records: {yahoo_count}/{len(price_data)}") + + assert yahoo_count > 0, "Should have price data from Yahoo Finance" + else: + print(f"Price data request failed: {response.status_code}") + + except Exception as e: + print(f"โŒ Error testing price data: {e}") + + +if __name__ == "__main__": + def run_tests(): + test_instance = TestSECEdgar15Year() + test_instance.setup_method() + + try: + test_instance.test_sec_only_financial_data_aapl() + test_instance.test_multiple_tickers_sec_data() + test_instance.test_no_yfinance_plus_dependency() + test_instance.test_price_data_separation() + print("\nโœ… All tests completed successfully!") + except Exception as e: + print(f"\nโŒ Test failed: {e}") + raise + + # Run the tests + run_tests() \ No newline at end of file diff --git a/tests/test_simple.py b/tests/test_simple.py new file mode 100644 index 0000000..a48e634 --- /dev/null +++ b/tests/test_simple.py @@ -0,0 +1,85 @@ +""" +Simple test to verify basic functionality +""" + +import sys +import os + +# Add the parent directory to the path to import the app +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +def test_imports(): + """Test that all modules can be imported""" + try: + from app.main import app + assert app is not None + print("โœ… FastAPI app imports successfully") + except Exception as e: + print(f"โŒ FastAPI app import failed: {e}") + assert False, f"App import failed: {e}" + +def test_models(): + """Test that models can be imported""" + try: + from app.models.financial import Company, FinancialData, CalculatedMetrics + assert Company is not None + assert FinancialData is not None + assert CalculatedMetrics is not None + print("โœ… Database models import successfully") + except Exception as e: + print(f"โŒ Models import failed: {e}") + assert False, f"Models import failed: {e}" + +def test_schemas(): + """Test that schemas can be imported""" + try: + from app.schemas.financial import FinancialDataRequest, FinancialDataResponse + assert FinancialDataRequest is not None + assert FinancialDataResponse is not None + print("โœ… Pydantic schemas import successfully") + except Exception as e: + print(f"โŒ Schemas import failed: {e}") + assert False, f"Schemas import failed: {e}" + +def test_config(): + """Test configuration""" + try: + from app.core.config import settings + assert settings.APP_NAME in ["Stock Oracle", "Stock_Oracle"] # Allow both values + assert settings.API_PREFIX == "/api/v1" + print("โœ… Configuration works correctly") + except Exception as e: + print(f"โŒ Configuration failed: {e}") + assert False, f"Configuration failed: {e}" + +def test_openapi_spec(): + """Test OpenAPI specification generation""" + try: + from app.main import app + openapi_schema = app.openapi() + + assert "openapi" in openapi_schema + assert "info" in openapi_schema + assert "paths" in openapi_schema + + # Check that key endpoints are documented + paths = openapi_schema["paths"] + assert "/api/v1/health" in paths + assert "/api/v1/financial/data" in paths + assert "/api/v1/metadata/catalog" in paths + + print("โœ… OpenAPI specification generated successfully") + except Exception as e: + print(f"โŒ OpenAPI generation failed: {e}") + assert False, f"OpenAPI generation failed: {e}" + +if __name__ == "__main__": + print("Running simple functionality tests...") + + test_imports() + test_models() + test_schemas() + test_config() + test_openapi_spec() + + print("\n๐ŸŽ‰ All simple tests passed!") \ No newline at end of file diff --git a/tests/test_simple_real_data.py b/tests/test_simple_real_data.py new file mode 100644 index 0000000..bd8980c --- /dev/null +++ b/tests/test_simple_real_data.py @@ -0,0 +1,100 @@ +""" +Simple test to verify real SEC data is working +""" + +import requests +import yfinance_plus as yf +from datetime import datetime + +API_URL = "http://localhost:18001/api/v1" + +def test_apple_real_data(): + """Test AAPL with a fresh date range""" + print("=== Testing AAPL Real SEC Data ===") + + # Use a different date range to avoid conflicts + response = requests.post( + f"{API_URL}/financial/data", + json={ + "ticker": "AAPL", + "start_date": "2023-01-01", + "end_date": "2023-12-31", + "period_type": "all", + "include_metrics": True, + "force_refresh": False # Don't force refresh initially + } + ) + + if response.status_code == 200: + data = response.json() + print(f"โœ… Success! Got {len(data['financial_data'])} periods") + + # Check data quality + for i, fd in enumerate(data['financial_data']): + print(f"\nPeriod {i+1}: {fd['period_date']}") + print(f" Revenue: ${fd.get('revenue', 0):,.0f}") + print(f" EPS: ${fd.get('eps', 0):.2f}") + print(f" Shares Outstanding: {fd.get('shares_outstanding', 0):,.0f}") + print(f" Market Cap: ${fd.get('market_cap', 0):,.0f}") + print(f" P/E Ratio: {fd.get('pe_ratio', 0):.2f}") + print(f" Data Source: {fd.get('data_source', 'Unknown')}") + print(f" Is Estimated: {fd.get('is_estimated', 'Unknown')}") + + # Check if we got real data + if not fd.get('is_estimated', True): + print(" โœ… REAL DATA!") + else: + print(" โš ๏ธ Estimated data") + + return True + else: + print(f"โŒ Error: {response.status_code}") + print(response.text) + return False + +def compare_with_yfinance(): + """Compare with yfinance_plus for the same period""" + print("\n=== Comparing with YFinance_plus ===") + + try: + stock = yf.Ticker("AAPL") + quarterly_income = stock.quarterly_income_stmt + + if not quarterly_income.empty: + print("\nYFinance_plus Data:") + for i, quarter in enumerate(quarterly_income.columns[:4]): # Show 4 quarters + revenue = quarterly_income.loc['Total Revenue', quarter] if 'Total Revenue' in quarterly_income.index else 0 + net_income = quarterly_income.loc['Net Income', quarter] if 'Net Income' in quarterly_income.index else 0 + eps = quarterly_income.loc['Diluted EPS', quarter] if 'Diluted EPS' in quarterly_income.index else 0 + + print(f"\n Quarter {i+1}: {quarter}") + print(f" Revenue: ${revenue:,.0f}") + print(f" Net Income: ${net_income:,.0f}") + print(f" EPS: ${eps:.2f}") + + # Show company info + info = stock.info + print(f"\nYFinance_plus Company Info:") + print(f" Shares Outstanding: {info.get('sharesOutstanding', 0):,.0f}") + print(f" Market Cap: ${info.get('marketCap', 0):,.0f}") + print(f" Current Price: ${info.get('currentPrice', 0):.2f}") + + except Exception as e: + print(f"Error getting yfinance_plus data: {e}") + +def main(): + print("Testing Real SEC Financial Data Implementation") + print("=" * 60) + + # Test Stock Oracle + success = test_apple_real_data() + + if success: + # Compare with yfinance_plus + compare_with_yfinance() + + print("\n" + "=" * 60) + print("Test completed!") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_yfinance_comparison.py b/tests/test_yfinance_comparison.py new file mode 100644 index 0000000..4d0a506 --- /dev/null +++ b/tests/test_yfinance_comparison.py @@ -0,0 +1,190 @@ +""" +Test script to compare Stock Oracle financial data with direct yfinance data +""" + +import asyncio +import requests +import yfinance_plus as yf +from datetime import datetime, timezone +import pandas as pd +import json +from typing import Dict, Any + +# API configuration +API_URL = "http://localhost:18001/api/v1" + +def get_yfinance_data(ticker: str) -> Dict[str, Any]: + """Get financial data directly from yfinance""" + print(f"\n=== Getting yfinance data for {ticker} ===") + + # Create ticker object + stock = yf.Ticker(ticker) + + # Get basic info + info = stock.info + print(f"Company: {info.get('longName', 'N/A')}") + print(f"Sector: {info.get('sector', 'N/A')}") + print(f"Industry: {info.get('industry', 'N/A')}") + + # Get quarterly financial statements + quarterly_income = stock.quarterly_income_stmt + quarterly_balance = stock.quarterly_balance_sheet + quarterly_cashflow = stock.quarterly_cashflow + + # Get current price data + current_price = info.get('currentPrice', info.get('regularMarketPrice', 0)) + market_cap = info.get('marketCap', 0) + + print("\n=== Quarterly Income Statement ===") + if not quarterly_income.empty: + print(quarterly_income.iloc[:, 0:2]) # Show latest 2 quarters + + print("\n=== Quarterly Balance Sheet ===") + if not quarterly_balance.empty: + print(quarterly_balance.iloc[:, 0:2]) # Show latest 2 quarters + + print("\n=== Quarterly Cash Flow ===") + if not quarterly_cashflow.empty: + print(quarterly_cashflow.iloc[:, 0:2]) # Show latest 2 quarters + + # Extract specific metrics for comparison + yfinance_data = { + 'info': info, + 'quarterly_income': quarterly_income.to_dict() if not quarterly_income.empty else {}, + 'quarterly_balance': quarterly_balance.to_dict() if not quarterly_balance.empty else {}, + 'quarterly_cashflow': quarterly_cashflow.to_dict() if not quarterly_cashflow.empty else {}, + 'current_price': current_price, + 'market_cap': market_cap + } + + return yfinance_data + +def get_stock_oracle_data(ticker: str, start_date: str, end_date: str) -> Dict[str, Any]: + """Get financial data from Stock Oracle API""" + print(f"\n=== Getting Stock Oracle data for {ticker} ===") + + # Request data from API + response = requests.post( + f"{API_URL}/financial/data", + json={ + "ticker": ticker, + "start_date": start_date, + "end_date": end_date, + "period_type": "all", + "include_metrics": True, + "force_refresh": False + } + ) + + if response.status_code == 200: + data = response.json() + print(f"Company: {data['company']['name']}") + print(f"Sector: {data['company']['sector']}") + print(f"Industry: {data['company']['industry']}") + print(f"Number of periods: {len(data['financial_data'])}") + return data + else: + print(f"Error: {response.status_code} - {response.text}") + return None + +def compare_financial_metrics(yfinance_data: Dict, oracle_data: Dict): + """Compare financial metrics between yfinance and Stock Oracle""" + print("\n=== COMPARISON RESULTS ===") + + if not oracle_data or 'financial_data' not in oracle_data: + print("No Stock Oracle data to compare") + return + + # Get the latest quarter from Stock Oracle + latest_oracle = oracle_data['financial_data'][-1] if oracle_data['financial_data'] else None + + if not latest_oracle: + print("No financial data in Stock Oracle") + return + + print(f"\nStock Oracle Latest Period: {latest_oracle['period_date']}") + + # Compare basic metrics + print("\n--- Revenue Comparison ---") + oracle_revenue = latest_oracle.get('revenue', 0) + print(f"Stock Oracle Revenue: ${oracle_revenue:,.2f}") + + # Try to get revenue from yfinance + if 'quarterly_income' in yfinance_data and yfinance_data['quarterly_income']: + quarters = list(yfinance_data['quarterly_income'].keys()) + if quarters: + latest_quarter = quarters[0] + yf_revenue = yfinance_data['quarterly_income'][latest_quarter].get('Total Revenue', 0) + print(f"YFinance Revenue: ${yf_revenue:,.2f}") + if oracle_revenue > 0: + diff_pct = ((oracle_revenue - yf_revenue) / yf_revenue) * 100 + print(f"Difference: {diff_pct:.2f}%") + + print("\n--- Market Cap Comparison ---") + oracle_market_cap = latest_oracle.get('market_cap', 0) + yf_market_cap = yfinance_data.get('market_cap', 0) + print(f"Stock Oracle Market Cap: ${oracle_market_cap:,.2f}") + print(f"YFinance Market Cap: ${yf_market_cap:,.2f}") + if oracle_market_cap > 0 and yf_market_cap > 0: + diff_pct = ((oracle_market_cap - yf_market_cap) / yf_market_cap) * 100 + print(f"Difference: {diff_pct:.2f}%") + + print("\n--- P/E Ratio Comparison ---") + oracle_pe = latest_oracle.get('pe_ratio', 0) + yf_pe = yfinance_data['info'].get('trailingPE', 0) + print(f"Stock Oracle P/E: {oracle_pe:.2f}") + print(f"YFinance P/E: {yf_pe:.2f}") + if oracle_pe > 0 and yf_pe > 0: + diff_pct = ((oracle_pe - yf_pe) / yf_pe) * 100 + print(f"Difference: {diff_pct:.2f}%") + + print("\n--- Other Metrics from Stock Oracle ---") + print(f"EPS: ${latest_oracle.get('eps', 0):.2f}") + print(f"Total Assets: ${latest_oracle.get('total_assets', 0):,.2f}") + print(f"Total Equity: ${latest_oracle.get('total_equity', 0):,.2f}") + print(f"ROE: {latest_oracle.get('roe', 0):.2%}") + print(f"Net Margin: {latest_oracle.get('net_margin', 0):.2%}") + + # Check if we have real yfinance quarterly data + if 'quarterly_income' in yfinance_data and yfinance_data['quarterly_income']: + print("\n--- Available YFinance Metrics ---") + quarters = list(yfinance_data['quarterly_income'].keys()) + if quarters: + latest_quarter = quarters[0] + print(f"Latest YFinance Quarter: {latest_quarter}") + + # Show some key metrics from yfinance + income_data = yfinance_data['quarterly_income'][latest_quarter] + for key in ['Total Revenue', 'Net Income', 'Operating Income', 'Gross Profit']: + if key in income_data: + print(f"{key}: ${income_data[key]:,.2f}") + +def main(): + """Main comparison function""" + # Test with Apple + ticker = "AAPL" + start_date = "2024-01-01" + end_date = "2024-12-31" + + print(f"Comparing financial data for {ticker}") + print("=" * 60) + + # Get data from both sources + yfinance_data = get_yfinance_data(ticker) + oracle_data = get_stock_oracle_data(ticker, start_date, end_date) + + # Compare the data + compare_financial_metrics(yfinance_data, oracle_data) + + # Test with another ticker + print("\n" + "=" * 60) + ticker = "MSFT" + print(f"\nComparing financial data for {ticker}") + print("=" * 60) + + yfinance_data = get_yfinance_data(ticker) + oracle_data = get_stock_oracle_data(ticker, start_date, end_date) + compare_financial_metrics(yfinance_data, oracle_data) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_yfinance_fallback.py b/tests/test_yfinance_fallback.py new file mode 100644 index 0000000..9989514 --- /dev/null +++ b/tests/test_yfinance_fallback.py @@ -0,0 +1,75 @@ +""" +Test if yfinance_plus fallback is working +""" + +import yfinance_plus as yf +import requests + +def test_yfinance_plus_directly(): + """Test yfinance_plus directly""" + print("=== Testing yfinance_plus directly ===") + + try: + stock = yf.Ticker("AAPL") + info = stock.info + print(f"Company: {info.get('longName', 'N/A')}") + print(f"Shares Outstanding: {info.get('sharesOutstanding', 0):,.0f}") + + quarterly_income = stock.quarterly_income_stmt + print(f"Quarterly Income Statement shape: {quarterly_income.shape}") + + if not quarterly_income.empty: + latest_quarter = quarterly_income.columns[0] + print(f"Latest quarter: {latest_quarter}") + + revenue = quarterly_income.loc['Total Revenue', latest_quarter] if 'Total Revenue' in quarterly_income.index else None + print(f"Revenue: ${revenue:,.0f}") + + return True + + except Exception as e: + print(f"Error: {e}") + return False + +def test_api_manually(): + """Test API to see what's returned""" + print("\n=== Testing API manually ===") + + response = requests.post( + "http://localhost:18001/api/v1/financial/data", + json={ + "ticker": "AAPL", + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "period_type": "all", + "include_metrics": True, + "force_refresh": True # Force refresh to trigger data fetching + } + ) + + print(f"Status Code: {response.status_code}") + + if response.status_code == 200: + data = response.json() + print(f"Company: {data['company']['name']}") + print(f"Financial data count: {len(data['financial_data'])}") + + if data['financial_data']: + latest = data['financial_data'][-1] + print(f"Latest period: {latest['period_date']}") + print(f"Revenue: ${latest.get('revenue', 0):,.0f}") + print(f"Shares Outstanding: {latest.get('shares_outstanding', 0):,.0f}") + print(f"Data Source: {latest.get('data_source', 'Unknown')}") + print(f"Is Estimated: {latest.get('is_estimated', 'Unknown')}") + + else: + print(f"Error: {response.text}") + +if __name__ == "__main__": + # Test yfinance_plus directly first + yf_works = test_yfinance_plus_directly() + + if yf_works: + test_api_manually() + else: + print("yfinance_plus is not working properly") \ No newline at end of file diff --git a/yfinance_plus b/yfinance_plus new file mode 160000 index 0000000..d18976d --- /dev/null +++ b/yfinance_plus @@ -0,0 +1 @@ +Subproject commit d18976d4aa58c9df58c55f47a77b568d8ce8581e