dromerosm commited on
Commit
a8f56ca
Β·
1 Parent(s): 8cad073

Add initial setup for Stock Monitoring API with testing framework

Browse files

- Created requirements.txt for project dependencies including FastAPI, SQLAlchemy, and testing libraries.
- Implemented run_tests.sh script to automate API testing, ensuring server is running and dependencies are installed.
- Developed test_api.py for comprehensive API testing covering health checks, public and protected endpoints, authentication, data downloads, and SQL injection safety.
- Updated testing strategy to reflect new API architecture, removing outdated endpoints and parameters.

Files changed (11) hide show
  1. .dockerignore +70 -0
  2. .gitignore +9 -0
  3. Dockerfile +51 -0
  4. README.md +80 -3
  5. TECHNICAL.md +377 -0
  6. api/__init__.py +0 -0
  7. api/index.py +1322 -0
  8. config.yaml +26 -0
  9. requirements.txt +33 -0
  10. tests/run_tests.sh +56 -0
  11. tests/test_api.py +341 -0
.dockerignore ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Git and version control
2
+ .git
3
+ .gitignore
4
+ .gitattributes
5
+
6
+ # Python
7
+ __pycache__
8
+ *.pyc
9
+ *.pyo
10
+ *.pyd
11
+ .Python
12
+ env
13
+ pip-log.txt
14
+ pip-delete-this-directory.txt
15
+ .tox
16
+ .coverage
17
+ .coverage.*
18
+ .cache
19
+ nosetests.xml
20
+ coverage.xml
21
+ *.cover
22
+ *.log
23
+ .pytest_cache
24
+
25
+ # Virtual environments
26
+ .env
27
+ .venv
28
+ env/
29
+ venv/
30
+ ENV/
31
+ env.bak/
32
+ venv.bak/
33
+
34
+ # Development files
35
+ .vscode/
36
+ .idea/
37
+ *.swp
38
+ *.swo
39
+ *~
40
+
41
+ # OS
42
+ .DS_Store
43
+ .DS_Store?
44
+ ._*
45
+ .Spotlight-V100
46
+ .Trashes
47
+ ehthumbs.db
48
+ Thumbs.db
49
+
50
+ # Project specific
51
+ data_cache/
52
+ tests/
53
+ *.md
54
+ LICENSE
55
+ README.md
56
+
57
+ # Vercel specific
58
+ vercel.json
59
+ .vercel/
60
+
61
+ # Docker
62
+ dockerfile
63
+ Dockerfile
64
+ .dockerignore
65
+
66
+ # Node.js (if any)
67
+ node_modules/
68
+ npm-debug.log*
69
+ yarn-debug.log*
70
+ yarn-error.log*
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ __pycache__
3
+ *.pyc
4
+ .conda
5
+ .venv
6
+ .vscode
7
+ .DS_Store
8
+
9
+ data_cache/
Dockerfile ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Alpine-based Dockerfile for maximum security and minimal size
2
+ FROM python:3.12-alpine
3
+
4
+ # Set working directory
5
+ WORKDIR /app
6
+
7
+ # Install system dependencies (Alpine packages)
8
+ RUN apk update && apk upgrade && apk add --no-cache \
9
+ gcc \
10
+ g++ \
11
+ musl-dev \
12
+ libxml2-dev \
13
+ libxslt-dev \
14
+ libffi-dev \
15
+ openssl-dev \
16
+ mysql-dev \
17
+ pkgconfig \
18
+ curl \
19
+ && rm -rf /var/cache/apk/*
20
+
21
+ # Copy requirements first for better Docker layer caching
22
+ COPY requirements.txt .
23
+
24
+ # Install Python dependencies
25
+ RUN pip install --no-cache-dir --upgrade pip && \
26
+ pip install --no-cache-dir -r requirements.txt
27
+
28
+ # Copy application code
29
+ COPY ./api ./api
30
+ COPY ./static ./api/static
31
+ COPY config.yaml .
32
+
33
+ # Create non-root user for security
34
+ RUN adduser -D -u 1001 appuser && chown -R appuser:appuser /app
35
+ USER appuser
36
+
37
+ # Set environment variables for Hugging Face Spaces
38
+ ENV PYTHONUNBUFFERED=1
39
+ ENV HOST=0.0.0.0
40
+ ENV PORT=7860
41
+ ENV PROD=True
42
+
43
+ # Health check
44
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
45
+ CMD curl -f http://localhost:7860/ || exit 1
46
+
47
+ # Expose the standard Hugging Face Spaces port
48
+ EXPOSE 7860
49
+
50
+ # Run the application
51
+ CMD ["uvicorn", "api.index:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "4", "--log-level", "debug"]
README.md CHANGED
@@ -1,12 +1,89 @@
1
  ---
2
  title: Ticker Monitor Api
3
- emoji: πŸ“ˆ
4
  colorFrom: purple
5
- colorTo: blue
6
  sdk: docker
7
  pinned: false
8
  license: mit
9
  short_description: SP500 & NASDAQ ticker monitoring API
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Ticker Monitor Api
3
+ emoji: πŸ“‰
4
  colorFrom: purple
5
+ colorTo: pink
6
  sdk: docker
7
  pinned: false
8
  license: mit
9
  short_description: SP500 & NASDAQ ticker monitoring API
10
  ---
11
 
12
+ # Stock Monitoring API πŸ“ˆ
13
+
14
+ A FastAPI-based REST API for monitoring S&P 500 and Nasdaq 100 stock tickers with real-time data updates.
15
+
16
+ ## Features
17
+
18
+ - **Stock Data Management**: Query and update tickers for S&P 500 and Nasdaq 100 indices
19
+ - **Automatic Data Updates**: Background tasks to fetch latest stock data from Yahoo Finance
20
+ - **RESTful API**: Clean, documented endpoints with OpenAPI/Swagger integration
21
+ - **MySQL Integration**: Persistent storage with async SQLAlchemy
22
+ - **Authentication**: Secure API key-based authentication
23
+ - **Health Monitoring**: Built-in health checks and version information
24
+
25
+ ## API Endpoints
26
+
27
+ ### Public Endpoints
28
+ - `GET /` - Health check and system information
29
+ - `GET /tickers` - List all available tickers with filtering options
30
+ - `GET /data/tickers/{ticker}` - Get historical data for a specific ticker
31
+
32
+ ### Protected Endpoints (Require API Key)
33
+ - `POST /tickers/update` - Update ticker list from Wikipedia sources
34
+ - `POST /tickers/update-async` - Async ticker updates with task tracking
35
+ - `POST /data/download-all` - Download latest stock data (bulk operation)
36
+ - `GET /tasks` - List background tasks
37
+ - `GET /tasks/{task_id}` - Get specific task status
38
+ - `DELETE /tasks/old` - Clean up old completed tasks
39
+
40
+ ## Usage
41
+
42
+ ### Authentication
43
+ Protected endpoints require an API key in the Authorization header:
44
+ ```bash
45
+ Authorization: Bearer your_api_key_here
46
+ ```
47
+
48
+ ### Example Requests
49
+ ```bash
50
+ # Get system health
51
+ curl https://your-space-name.hf.space/
52
+
53
+ # List S&P 500 tickers
54
+ curl "https://your-space-name.hf.space/tickers?is_sp500=true&limit=10"
55
+
56
+ # Get AAPL stock data (last 30 days)
57
+ curl "https://your-space-name.hf.space/data/tickers/AAPL?days=30"
58
+
59
+ # Update all stock data (requires API key)
60
+ curl -X POST "https://your-space-name.hf.space/data/download-all" \
61
+ -H "Authorization: Bearer your_api_key"
62
+ ```
63
+
64
+ ## Configuration
65
+
66
+ Set these environment variables in your Hugging Face Space:
67
+
68
+ - `MYSQL_USER` - Database username
69
+ - `MYSQL_PASSWORD` - Database password
70
+ - `MYSQL_HOST` - Database host
71
+ - `MYSQL_PORT` - Database port (default: 3306)
72
+ - `MYSQL_DB` - Database name
73
+ - `API_KEY` - Secure API key for protected endpoints
74
+
75
+ ## Technical Details
76
+
77
+ - **Framework**: FastAPI with async/await support
78
+ - **Database**: MySQL with SQLAlchemy async ORM
79
+ - **Data Sources**: Wikipedia (ticker lists), Yahoo Finance (stock data)
80
+ - **Deployment**: Docker container optimized for Hugging Face Spaces
81
+ - **Python**: 3.12 with production-ready dependencies
82
+
83
+ ## API Documentation
84
+
85
+ Once deployed, visit `/docs` for interactive Swagger documentation or `/redoc` for alternative API docs.
86
+
87
+ ---
88
+
89
+ *Built for Hugging Face Spaces with ❀️*
TECHNICAL.md ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Technical Documentation - Stock Monitoring API
2
+
3
+ This document provides comprehensive technical information about the Stock Monitoring API, including detailed setup instructions, API reference, testing procedures, and deployment guidelines.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Installation & Configuration](#installation--configuration)
8
+ - [Authentication](#authentication)
9
+ - [API Reference](#api-reference)
10
+ - [Testing](#testing)
11
+ - [Database](#database)
12
+ - [Static Files](#static-files)
13
+ - [Contributing](#contributing)
14
+ - [Development Status](#development-status)
15
+
16
+ ---
17
+
18
+ ## Installation & Configuration
19
+
20
+ ### Prerequisites
21
+
22
+ - Python 3.8 or higher
23
+ - MySQL database server
24
+ - Virtual environment (recommended)
25
+
26
+ ### Detailed Setup
27
+
28
+ 1. **Clone the repository:**
29
+ ```bash
30
+ git clone https://github.com/dromerosm/stock-monitoring.git
31
+ cd stock-monitoring
32
+ ```
33
+
34
+ 2. **Set up virtual environment (recommended):**
35
+ ```bash
36
+ python -m venv .venv
37
+ source .venv/bin/activate # On Windows: .venv\Scripts\activate
38
+ ```
39
+
40
+ 3. **Install dependencies:**
41
+ ```bash
42
+ pip install -r requirements.txt
43
+ ```
44
+
45
+ 4. **Environment Configuration:**
46
+ Create a `.env` file in the project root with the following variables:
47
+ ```env
48
+ # Database Configuration
49
+ MYSQL_USER=youruser
50
+ MYSQL_PASSWORD=yourpassword
51
+ MYSQL_HOST=localhost
52
+ MYSQL_PORT=3306
53
+ MYSQL_DB=stockdb
54
+
55
+ # API Security - Generate a secure API key
56
+ API_KEY=your_secure_api_key_here
57
+
58
+ # Optional: Server Configuration
59
+ PORT=8000
60
+ ```
61
+
62
+ 5. **Database Setup:**
63
+ - Ensure MySQL server is running
64
+ - Create the database specified in `MYSQL_DB`
65
+ - Tables (`tickers` and `tasks`) will be created automatically on first run
66
+
67
+ 6. **Start the API:**
68
+ ```bash
69
+ python api/index.py
70
+ # Alternative with Uvicorn
71
+ uvicorn api.index:app --host 0.0.0.0 --port 8000
72
+ ```
73
+
74
+ ---
75
+
76
+ ## Authentication
77
+
78
+ ### API Key Security
79
+
80
+ πŸ”’ The API uses Bearer token authentication for protected endpoints. Include the API key in the Authorization header:
81
+
82
+ ```bash
83
+ Authorization: Bearer your_api_key_here
84
+ ```
85
+
86
+ ### Endpoint Classification
87
+
88
+ **Protected Endpoints** (require API key):
89
+ - `POST /tickers/update` - Manual ticker updates
90
+ - `POST /tickers/update-async` - Asynchronous ticker updates
91
+ - `GET /tasks` - List all background tasks
92
+ - `GET /tasks/{task_id}` - Get specific task details
93
+ - `DELETE /tasks/old` - Clean up old tasks
94
+
95
+ **Public Endpoints** (no authentication required):
96
+ - `GET /` - Health check and system status
97
+ - `GET /tickers` - Read-only ticker data access
98
+
99
+ ---
100
+
101
+ ## API Reference
102
+
103
+ ### Health Check Endpoint
104
+
105
+ #### `GET /`
106
+
107
+ Returns comprehensive system health information including database connectivity, versions, and performance metrics.
108
+
109
+ **Response Example:**
110
+ ```json
111
+ {
112
+ "status": "healthy",
113
+ "timestamp": "2025-07-19T17:38:26+00:00",
114
+ "versions": {
115
+ "python": "3.12.0",
116
+ "uvicorn": "0.24.0",
117
+ "fastapi": "0.110.0",
118
+ "sqlalchemy": "2.0.30",
119
+ "pandas": "2.2.2"
120
+ },
121
+ "database": {
122
+ "connected": true,
123
+ "tickers_table": true,
124
+ "tasks_table": true
125
+ },
126
+ "db_check_seconds": 0.0123
127
+ }
128
+ ```
129
+
130
+ ### Ticker Endpoints
131
+
132
+ #### `GET /tickers`
133
+
134
+ Retrieve ticker data with optional filtering.
135
+
136
+ **Query Parameters:**
137
+ - `is_sp500` (boolean): Filter by S&P 500 membership
138
+ - `is_nasdaq` (boolean): Filter by Nasdaq 100 membership
139
+ - `limit` (integer): Maximum number of results
140
+
141
+ **Example:**
142
+ ```bash
143
+ curl 'http://localhost:8000/tickers?is_sp500=true&limit=10'
144
+ ```
145
+
146
+ #### `POST /tickers/update` πŸ”’
147
+
148
+ Performs synchronous ticker data update from Wikipedia sources.
149
+
150
+ **Request Body:**
151
+ ```json
152
+ {
153
+ "force_refresh": true // Optional: bypass cache and force update
154
+ }
155
+ ```
156
+
157
+ **Example:**
158
+ ```bash
159
+ curl -X POST 'http://localhost:8000/tickers/update' \
160
+ -H 'Content-Type: application/json' \
161
+ -H 'Authorization: Bearer your_api_key_here' \
162
+ -d '{"force_refresh": true}'
163
+ ```
164
+
165
+ #### `POST /tickers/update-async` πŸ”’
166
+
167
+ Launches asynchronous background task for ticker updates.
168
+
169
+ **Request Body:**
170
+ ```json
171
+ {
172
+ "force_refresh": false // Optional: bypass cache and force update
173
+ }
174
+ ```
175
+
176
+ **Response:**
177
+ ```json
178
+ {
179
+ "task_id": "uuid-string",
180
+ "status": "pending",
181
+ "message": "Background update task started"
182
+ }
183
+ ```
184
+
185
+ **Example:**
186
+ ```bash
187
+ curl -X POST 'http://localhost:8000/tickers/update-async' \
188
+ -H 'Content-Type: application/json' \
189
+ -H 'Authorization: Bearer your_api_key_here' \
190
+ -d '{"force_refresh": false}'
191
+ ```
192
+
193
+ ### Task Management Endpoints
194
+
195
+ #### `GET /tasks` πŸ”’
196
+
197
+ List all background tasks with their current status and results.
198
+
199
+ #### `GET /tasks/{task_id}` πŸ”’
200
+
201
+ Get detailed information about a specific background task.
202
+
203
+ **Path Parameters:**
204
+ - `task_id` (string): UUID of the task
205
+
206
+ #### `DELETE /tasks/old` πŸ”’
207
+
208
+ Remove completed tasks older than 1 hour (3600 seconds) to maintain database performance.
209
+
210
+ ---
211
+
212
+ ## Testing
213
+
214
+ ### Automated Test Suite
215
+
216
+ The project includes a comprehensive testing framework that validates API functionality, security, and data integrity.
217
+
218
+ ### Test Setup and Execution
219
+
220
+ 1. **Start the API server:**
221
+ ```bash
222
+ source .venv/bin/activate # if using virtual environment
223
+ python api/index.py
224
+ ```
225
+
226
+ 2. **Run the test suite:**
227
+ ```bash
228
+ cd tests
229
+ chmod +x run_tests.sh # First time only
230
+ ./run_tests.sh
231
+ ```
232
+
233
+ ### Test Coverage
234
+
235
+ The test suite validates the following areas:
236
+
237
+ - βœ… **Authentication Security**: Verifies that protected endpoints properly reject unauthorized requests
238
+ - βœ… **Public Access**: Confirms public endpoints function without authentication
239
+ - βœ… **SQL Injection Protection**: Tests input sanitization and parameterized queries
240
+ - βœ… **Complete Workflow**: End-to-end testing of task creation, monitoring, and cleanup
241
+ - βœ… **Error Handling**: Validates proper HTTP status codes and error responses
242
+ - βœ… **Data Integrity**: Ensures ticker data accuracy and consistency
243
+
244
+ ### Test Files Structure
245
+
246
+ - `tests/test_api.py` - Main test script with comprehensive coverage
247
+ - `tests/run_tests.sh` - Test runner with prerequisite checks
248
+
249
+ ### Example Test Output
250
+
251
+ ```
252
+ πŸ§ͺ Starting Stock Monitoring API Tests
253
+ πŸ”— Base URL: http://localhost:8000
254
+ πŸ”‘ API Key: Vsb5Zkujk2...
255
+
256
+ ============================================================
257
+ πŸ§ͺ Health Check (Public Endpoint)
258
+ ============================================================
259
+ βœ… GET /
260
+ Expected: 200, Got: 200
261
+ πŸ“Š Status: healthy
262
+ πŸ• Timestamp: 2025-07-30T15:25:49+02:00
263
+ πŸ’Ύ DB Connected: True
264
+
265
+ ============================================================
266
+ πŸ§ͺ Authentication Tests
267
+ ============================================================
268
+ βœ… POST /tickers/update (No Auth)
269
+ Expected: 401/403, Got: 403
270
+ βœ… GET /tasks (No Auth)
271
+ Expected: 401/403, Got: 403
272
+
273
+ ============================================================
274
+ πŸ§ͺ SQL Injection Tests
275
+ ============================================================
276
+ βœ… GET /tickers?limit='; DROP TABLE tickers; --
277
+ Expected: 422, Got: 422
278
+ πŸ”’ SQL injection attempt blocked
279
+
280
+ πŸŽ‰ ALL TESTS PASSED! βœ…
281
+ ```
282
+
283
+ ---
284
+
285
+ ## Database
286
+
287
+ ### Architecture
288
+
289
+ The application uses MySQL with SQLAlchemy ORM for database operations, providing:
290
+
291
+ - **Async Operations**: Non-blocking database interactions using `aiomysql`
292
+ - **Connection Pooling**: Efficient connection management
293
+ - **Auto Schema Creation**: Tables created automatically on startup
294
+
295
+ ### Database Schema
296
+
297
+ #### `tickers` Table
298
+ Stores stock ticker information for S&P 500 and Nasdaq 100 indices.
299
+
300
+ #### `tasks` Table
301
+ Tracks background task execution status and results.
302
+
303
+ ### Database Configuration
304
+
305
+ Configure database connection via environment variables:
306
+ ```env
307
+ MYSQL_USER=youruser
308
+ MYSQL_PASSWORD=yourpassword
309
+ MYSQL_HOST=localhost
310
+ MYSQL_PORT=3306
311
+ MYSQL_DB=stockdb
312
+ ```
313
+
314
+ ---
315
+
316
+ ## Static Files
317
+
318
+ The API serves static content including:
319
+
320
+ - **Favicon**: Available at `/favicon.ico` and `/static/favicon.ico`
321
+ - **Static Assets**: Served from the `/static/` directory
322
+
323
+ ---
324
+
325
+ ## Contributing
326
+
327
+ ### Development Workflow
328
+
329
+ 1. **Fork the repository**
330
+ 2. **Create a feature branch:**
331
+ ```bash
332
+ git checkout -b feature/my-new-feature
333
+ ```
334
+ 3. **Make your changes and add tests**
335
+ 4. **Run the test suite to ensure everything works**
336
+ 5. **Commit your changes:**
337
+ ```bash
338
+ git commit -m 'Add new feature: description'
339
+ ```
340
+ 6. **Push to your fork:**
341
+ ```bash
342
+ git push origin feature/my-new-feature
343
+ ```
344
+ 7. **Open a pull request**
345
+
346
+ ### Code Standards
347
+
348
+ - Follow PEP 8 Python style guide
349
+ - Include docstrings for all functions and classes
350
+ - Add appropriate error handling
351
+ - Write tests for new functionality
352
+ - Ensure all tests pass before submitting
353
+
354
+ ---
355
+
356
+ ## Development Status
357
+
358
+ ### Recently Completed βœ…
359
+
360
+ - βœ… **Security Implementation** - API key authentication for protected endpoints
361
+ - βœ… **SQL Injection Prevention** - Replaced unsafe `text()` operations with parameterized queries
362
+ - βœ… **UTC Timezone Standardization** - All timestamps now use UTC for consistency
363
+ - βœ… **Enhanced Logging** - Added comprehensive logging in TaskManager for database operations
364
+
365
+ ### Pending Tasks
366
+
367
+ - **Apple Touch Icons**: Implement missing `/apple-touch-icon-precomposed.png` and `/apple-touch-icon.png` endpoints
368
+ - **Enhanced Request Logging**: Add detailed request/response logging for improved debugging and monitoring
369
+ - **Rate Limiting**: Implement API rate limiting for production use
370
+ - **Docker Support**: Add containerization for easier deployment
371
+
372
+ ### Architecture Improvements Under Consideration
373
+
374
+ - **Caching Layer**: Redis integration for improved performance
375
+ - **Message Queue**: Background task processing with Celery
376
+ - **API Versioning**: Implement versioned API endpoints for backward compatibility
377
+ - **Metrics Collection**: Prometheus metrics for monitoring and alerting
api/__init__.py ADDED
File without changes
api/index.py ADDED
@@ -0,0 +1,1322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # api/index.py
2
+
3
+ import os
4
+ import logging
5
+ import time
6
+ from datetime import datetime, timedelta
7
+ from datetime import date as datetime_date
8
+ from typing import List, Dict, Any, Optional, AsyncGenerator
9
+ import asyncio
10
+ from contextlib import asynccontextmanager
11
+
12
+ import yaml
13
+ import importlib.metadata
14
+ import pytz
15
+ from fastapi import FastAPI, HTTPException, BackgroundTasks, Depends, Security
16
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
17
+ from pydantic import BaseModel, Field
18
+ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
19
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
20
+ from sqlalchemy import String, Integer, DateTime, select, delete, Float, Index
21
+ from sqlalchemy.types import Date as SQLAlchemyDate
22
+ from dotenv import load_dotenv, find_dotenv
23
+ from sqlalchemy.pool import NullPool
24
+
25
+ import requests
26
+ import pandas as pd
27
+ from io import StringIO
28
+ import ssl
29
+ import certifi
30
+ import aiohttp
31
+ import platform
32
+ import yfinance as yf
33
+
34
+ # --- Favicon/Static imports ---
35
+ import os
36
+ from fastapi.staticfiles import StaticFiles
37
+ from fastapi.responses import FileResponse
38
+
39
+ from fastapi.responses import JSONResponse
40
+
41
+
42
+ # --- MODELS ---
43
+
44
+ class Base(DeclarativeBase):
45
+ pass
46
+
47
+
48
+ class Ticker(Base):
49
+ __tablename__ = "tickers"
50
+
51
+ ticker: Mapped[str] = mapped_column(String(10), primary_key=True)
52
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
53
+ sector: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
54
+ subindustry: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
55
+ is_sp500: Mapped[int] = mapped_column(Integer, default=0)
56
+ is_nasdaq100: Mapped[int] = mapped_column(Integer, default=0)
57
+ last_updated: Mapped[datetime] = mapped_column(DateTime)
58
+
59
+
60
+ class TickerData(Base):
61
+ __tablename__ = "ticker_data"
62
+
63
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
64
+ ticker: Mapped[str] = mapped_column(String(10), nullable=False)
65
+ date: Mapped[datetime_date] = mapped_column(SQLAlchemyDate, nullable=False)
66
+ open: Mapped[float] = mapped_column(Float, nullable=False)
67
+ high: Mapped[float] = mapped_column(Float, nullable=False)
68
+ low: Mapped[float] = mapped_column(Float, nullable=False)
69
+ close: Mapped[float] = mapped_column(Float, nullable=False)
70
+ volume: Mapped[int] = mapped_column(Integer, nullable=False)
71
+ created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
72
+
73
+ __table_args__ = (
74
+ Index('idx_ticker_date', 'ticker', 'date', unique=True),
75
+ Index('idx_ticker', 'ticker'),
76
+ Index('idx_date', 'date'),
77
+ )
78
+
79
+
80
+ # --- PYDANTIC MODELS ---
81
+
82
+ class TickerResponse(BaseModel):
83
+ ticker: str
84
+ name: str
85
+ sector: Optional[str]
86
+ subindustry: Optional[str]
87
+ is_sp500: bool
88
+ is_nasdaq100: bool
89
+ last_updated: datetime
90
+
91
+
92
+ class UpdateTickersRequest(BaseModel):
93
+ force_refresh: bool = Field(default=False, description="Force refresh even if data is recent")
94
+
95
+
96
+ class UpdateTickersResponse(BaseModel):
97
+ success: bool
98
+ message: str
99
+ total_tickers: int
100
+ sp500_count: int
101
+ nasdaq100_count: int
102
+ updated_at: datetime
103
+
104
+
105
+ class TaskStatus(BaseModel):
106
+ task_id: str
107
+ status: str # pending, running, completed, failed
108
+ message: Optional[str] = None
109
+ result: Optional[Dict[str, Any]] = None
110
+ created_at: datetime
111
+
112
+
113
+ class TickerDataResponse(BaseModel):
114
+ ticker: str
115
+ date: datetime_date
116
+ open: float
117
+ high: float
118
+ low: float
119
+ close: float
120
+ volume: int
121
+ created_at: datetime
122
+
123
+
124
+ class DownloadDataRequest(BaseModel):
125
+ tickers: Optional[List[str]] = Field(default=None, description="Specific tickers to download. If not provided, downloads all available tickers")
126
+ force_refresh: bool = Field(default=False, description="Force refresh even if data exists")
127
+
128
+
129
+ class DownloadDataResponse(BaseModel):
130
+ success: bool
131
+ message: str
132
+ tickers_processed: int
133
+ records_created: int
134
+ records_updated: int
135
+ date_range: Dict[str, str] # start_date, end_date
136
+ updated_at: datetime
137
+
138
+
139
+ # --- AUTHENTICATION ---
140
+
141
+ security = HTTPBearer()
142
+
143
+ async def verify_api_key(credentials: HTTPAuthorizationCredentials = Security(security)):
144
+ """
145
+ Verify API key from Authorization header.
146
+ Expected format: Authorization: Bearer <api_key>
147
+ """
148
+ api_key = os.getenv("API_KEY")
149
+ if not api_key:
150
+ raise HTTPException(
151
+ status_code=500,
152
+ detail="API key not configured on server"
153
+ )
154
+
155
+ if credentials.credentials != api_key:
156
+ raise HTTPException(
157
+ status_code=401,
158
+ detail="Invalid API key"
159
+ )
160
+
161
+ return credentials.credentials
162
+
163
+
164
+ # --- CONFIGURATION ---
165
+
166
+ class Config:
167
+ def __init__(self):
168
+ load_dotenv(find_dotenv())
169
+ self.config = self._load_yaml_config()
170
+ self._setup_logging()
171
+
172
+ def _load_yaml_config(self, config_path='config.yaml'):
173
+ try:
174
+ with open(config_path, 'r') as f:
175
+ return yaml.safe_load(f)
176
+ except FileNotFoundError:
177
+ logging.warning(f"Config file '{config_path}' not found. Using defaults.")
178
+ return self._get_default_config()
179
+
180
+ def _get_default_config(self):
181
+ return {
182
+ 'logging': {'level': 'INFO', 'log_file': 'data_cache/api.log'},
183
+ 'data_sources': {
184
+ 'sp500': {
185
+ 'url': 'https://en.wikipedia.org/wiki/List_of_S%26P_500_companies',
186
+ 'ticker_column': 'Symbol',
187
+ 'name_column': 'Security'
188
+ },
189
+ 'nasdaq100': {
190
+ 'url': 'https://en.wikipedia.org/wiki/Nasdaq-100',
191
+ 'ticker_column': 'Ticker',
192
+ 'name_column': 'Company'
193
+ }
194
+ },
195
+ 'database': {'pool_size': 5, 'max_overflow': 10}
196
+ }
197
+
198
+ def _setup_logging(self):
199
+ log_config = self.config.get('logging', {})
200
+ log_file = log_config.get('log_file', 'data_cache/api.log')
201
+ prod_mode = os.getenv("PROD", "False") == "True"
202
+
203
+ handlers = [logging.StreamHandler()]
204
+ if not prod_mode:
205
+ os.makedirs(os.path.dirname(log_file), exist_ok=True)
206
+ handlers.insert(0, logging.FileHandler(log_file))
207
+
208
+ logging.basicConfig(
209
+ level=getattr(logging, log_config.get('level', 'INFO').upper()),
210
+ format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
211
+ handlers=handlers,
212
+ datefmt='%Y-%m-%d %H:%M:%S'
213
+ )
214
+
215
+ @property
216
+ def database_url(self) -> str:
217
+ user = os.getenv("MYSQL_USER")
218
+ password = os.getenv("MYSQL_PASSWORD")
219
+ host = os.getenv("MYSQL_HOST")
220
+ port = os.getenv("MYSQL_PORT")
221
+ db = os.getenv("MYSQL_DB")
222
+
223
+ if not all([user, password]):
224
+ raise ValueError("MySQL credentials not found in environment variables")
225
+
226
+ return f"mysql+aiomysql://{user}:{password}@{host}:{port}/{db}"
227
+
228
+
229
+ # --- SERVICES ---
230
+
231
+ class TickerService:
232
+ def __init__(self, config: Config):
233
+ self.config = config
234
+ self.logger = logging.getLogger(__name__)
235
+
236
+ async def get_tickers_from_wikipedia(
237
+ self, url: str, ticker_column: str, name_column: str,
238
+ sector_column: Optional[str] = None, subindustry_column: Optional[str] = None
239
+ ) -> List[tuple[str, str, Optional[str], Optional[str]]]:
240
+ """Async version fetching ticker, name, sector, and subindustry from Wikipedia."""
241
+ try:
242
+ ssl_context = ssl.create_default_context(cafile=certifi.where())
243
+ connector = aiohttp.TCPConnector(ssl=ssl_context)
244
+ async with aiohttp.ClientSession(connector=connector) as session:
245
+ headers = {'User-Agent': 'Mozilla/5.0 (compatible; MarketDataAPI/1.0)'}
246
+ async with session.get(url, headers=headers) as response:
247
+ response.raise_for_status()
248
+ html_content = await response.text()
249
+
250
+ tables = pd.read_html(StringIO(html_content))
251
+ columns_needed = [ticker_column, name_column]
252
+ if sector_column:
253
+ columns_needed.append(sector_column)
254
+ if subindustry_column:
255
+ columns_needed.append(subindustry_column)
256
+
257
+ df = next((table for table in tables if all(col in table.columns for col in columns_needed)), None)
258
+ if df is None:
259
+ self.logger.error(f"Could not find columns {columns_needed} on {url}")
260
+ return []
261
+
262
+ entries = df[columns_needed].dropna(subset=[ticker_column])
263
+ self.logger.info(f"Fetched {len(entries)} rows from {url}")
264
+
265
+ results: List[tuple[str, str, Optional[str], Optional[str]]] = []
266
+ for _, row in entries.iterrows():
267
+ ticker = str(row[ticker_column]).strip()
268
+ name = str(row[name_column]).strip()
269
+ sector = str(row[sector_column]).strip() if sector_column and sector_column in row and pd.notna(row[sector_column]) else None
270
+ subindustry = str(row[subindustry_column]).strip() if subindustry_column and subindustry_column in row and pd.notna(row[subindustry_column]) else None
271
+ results.append((ticker, name, sector, subindustry))
272
+ return results
273
+ except Exception as e:
274
+ self.logger.error(f"Failed to fetch tickers and names from {url}: {e}")
275
+ return []
276
+
277
+
278
+ async def get_sp500_tickers(self) -> List[tuple[str, str, Optional[str], Optional[str]]]:
279
+ cfg = self.config.config.get('data_sources', {}).get('sp500', {})
280
+ return await self.get_tickers_from_wikipedia(
281
+ cfg.get('url'),
282
+ cfg.get('ticker_column'),
283
+ cfg.get('name_column'),
284
+ cfg.get('sector_column'),
285
+ cfg.get('subindustry_column')
286
+ )
287
+
288
+ async def get_nasdaq100_tickers(self) -> List[tuple[str, str, Optional[str], Optional[str]]]:
289
+ cfg = self.config.config.get('data_sources', {}).get('nasdaq100', {})
290
+ return await self.get_tickers_from_wikipedia(
291
+ cfg.get('url'),
292
+ cfg.get('ticker_column'),
293
+ cfg.get('name_column'),
294
+ cfg.get('sector_column'),
295
+ cfg.get('subindustry_column')
296
+ )
297
+
298
+ async def update_tickers_in_db(self, session: AsyncSession, force_refresh: bool = False) -> Dict[str, Any]:
299
+ """
300
+ Updates tickers table with latest data from Wikipedia sources, unless data is less than 1 day old (unless force_refresh).
301
+ """
302
+ try:
303
+ # Check if tickers were updated in the last 24h
304
+ now = datetime.now(pytz.UTC)
305
+ result = await session.execute(select(Ticker.last_updated).order_by(Ticker.last_updated.desc()).limit(1))
306
+ last = result.scalar()
307
+ if last and not force_refresh:
308
+ # Ensure 'last' is timezone aware
309
+ if last.tzinfo is None:
310
+ last = pytz.UTC.localize(last)
311
+ delta = now - last
312
+ if delta.total_seconds() < 86400:
313
+ self.logger.info(f"Tickers not updated: last update {last.isoformat()} < 1 day ago.")
314
+ from sqlalchemy import func
315
+ total_tickers = await session.scalar(select(func.count()).select_from(Ticker))
316
+ sp500_count = await session.scalar(select(func.count()).select_from(Ticker).where(Ticker.is_sp500 == 1))
317
+ nasdaq100_count = await session.scalar(select(func.count()).select_from(Ticker).where(Ticker.is_nasdaq100 == 1))
318
+ return {
319
+ "total_tickers": total_tickers,
320
+ "sp500_count": sp500_count,
321
+ "nasdaq100_count": nasdaq100_count,
322
+ "updated_at": last,
323
+ "not_updated_reason": "Tickers not updated: last update was less than 1 day ago. Use force_refresh to override."
324
+ }
325
+
326
+ sp500_list = await self.get_sp500_tickers()
327
+ nasdaq_list = await self.get_nasdaq100_tickers()
328
+ combined = sp500_list + nasdaq_list
329
+ ticker_dict = {}
330
+ for t, n, s, sub in combined:
331
+ ticker_dict[t] = {
332
+ "name": n,
333
+ "sector": s,
334
+ "subindustry": sub,
335
+ "is_sp500": 1 if t in [x[0] for x in sp500_list] else 0,
336
+ "is_nasdaq100": 1 if t in [x[0] for x in nasdaq_list] else 0
337
+ }
338
+ all_tickers = sorted(ticker_dict.keys())
339
+
340
+ current_time = now
341
+
342
+ await session.execute(delete(Ticker))
343
+
344
+ ticker_objects = [
345
+ Ticker(
346
+ ticker=t,
347
+ name=ticker_dict[t]["name"],
348
+ sector=ticker_dict[t]["sector"],
349
+ subindustry=ticker_dict[t]["subindustry"],
350
+ is_sp500=ticker_dict[t]["is_sp500"],
351
+ is_nasdaq100=ticker_dict[t]["is_nasdaq100"],
352
+ last_updated=current_time
353
+ )
354
+ for t in all_tickers
355
+ ]
356
+
357
+ session.add_all(ticker_objects)
358
+ await session.commit()
359
+
360
+ result = {
361
+ "total_tickers": len(all_tickers),
362
+ "sp500_count": len(sp500_list),
363
+ "nasdaq100_count": len(nasdaq_list),
364
+ "updated_at": current_time
365
+ }
366
+ self.logger.info(
367
+ "Tickers table updated: total=%d, sp500=%d, nasdaq100=%d at %s",
368
+ result["total_tickers"],
369
+ result["sp500_count"],
370
+ result["nasdaq100_count"],
371
+ result["updated_at"].isoformat()
372
+ )
373
+ return result
374
+ except Exception as e:
375
+ await session.rollback()
376
+ self.logger.error(f"Failed to update tickers: {e}")
377
+ raise
378
+
379
+
380
+ class YFinanceService:
381
+ def __init__(self, config: Config):
382
+ self.config = config
383
+ self.logger = logging.getLogger(__name__)
384
+
385
+ async def check_tickers_freshness(self, session: AsyncSession) -> bool:
386
+ """
387
+ Check if tickers were updated within the last week (7 days).
388
+ Returns True if fresh, False if need update.
389
+ """
390
+ try:
391
+ now = datetime.now(pytz.UTC)
392
+ result = await session.execute(
393
+ select(Ticker.last_updated).order_by(Ticker.last_updated.desc()).limit(1)
394
+ )
395
+ last_update = result.scalar()
396
+
397
+ if not last_update:
398
+ self.logger.info("No tickers found in database")
399
+ return False
400
+
401
+ # Ensure timezone awareness
402
+ if last_update.tzinfo is None:
403
+ last_update = pytz.UTC.localize(last_update)
404
+
405
+ delta = now - last_update
406
+ is_fresh = delta.total_seconds() < (7 * 24 * 3600) # 7 days
407
+
408
+ self.logger.info(f"Tickers last updated: {last_update.isoformat()}, Fresh: {is_fresh}")
409
+ return is_fresh
410
+
411
+ except Exception as e:
412
+ self.logger.error(f"Error checking ticker freshness: {e}")
413
+ return False
414
+
415
+ async def check_ticker_data_freshness(self, session: AsyncSession) -> bool:
416
+ """
417
+ Check if ticker data was updated within the last day (24 hours).
418
+ Returns True if fresh, False if need update.
419
+ """
420
+ try:
421
+ now = datetime.now(pytz.UTC)
422
+ result = await session.execute(
423
+ select(TickerData.created_at).order_by(TickerData.created_at.desc()).limit(1)
424
+ )
425
+ last_update = result.scalar()
426
+
427
+ if not last_update:
428
+ self.logger.info("No ticker data found in database")
429
+ return False
430
+
431
+ # Ensure timezone awareness
432
+ if last_update.tzinfo is None:
433
+ last_update = pytz.UTC.localize(last_update)
434
+
435
+ delta = now - last_update
436
+ is_fresh = delta.total_seconds() < (24 * 3600) # 24 hours
437
+
438
+ self.logger.info(f"Ticker data last updated: {last_update.isoformat()}, Fresh: {is_fresh}")
439
+ return is_fresh
440
+
441
+ except Exception as e:
442
+ self.logger.error(f"Error checking ticker data freshness: {e}")
443
+ return False
444
+
445
+ async def clear_and_bulk_insert_ticker_data(self, session: AsyncSession, ticker_list: List[str]) -> Dict[str, Any]:
446
+ """
447
+ Clear all ticker data and insert new data in bulk with chunking for better performance.
448
+ Uses bulk delete and bulk insert with chunks of 500 records.
449
+ """
450
+ try:
451
+ # Start timing for total end-to-end process
452
+ total_start_time = time.perf_counter()
453
+
454
+ self.logger.info(f"Starting bulk data refresh for {len(ticker_list)} tickers (clear and insert)")
455
+
456
+ # Start timing for data download
457
+ download_start_time = time.perf_counter()
458
+
459
+ # Download data for all tickers at once using period
460
+ data = yf.download(ticker_list, period='1mo', group_by='ticker', progress=True, auto_adjust=True)
461
+
462
+ download_end_time = time.perf_counter()
463
+ download_duration = download_end_time - download_start_time
464
+ self.logger.info(f"DEBUG: Data download completed in {download_duration:.2f} seconds for {len(ticker_list)} tickers")
465
+
466
+ if data.empty:
467
+ self.logger.warning("No data found for any tickers")
468
+ return {
469
+ "created": 0,
470
+ "updated": 0,
471
+ "date_range": {"start_date": "", "end_date": ""}
472
+ }
473
+
474
+ # Start timing for database operations
475
+ db_start_time = time.perf_counter()
476
+
477
+ # Clear all existing ticker data
478
+ self.logger.info("Clearing all existing ticker data...")
479
+ clear_start = time.perf_counter()
480
+ await session.execute(delete(TickerData))
481
+ clear_end = time.perf_counter()
482
+ self.logger.info(f"DEBUG: Data cleared in {clear_end - clear_start:.2f} seconds")
483
+
484
+ # Prepare data for bulk insert
485
+ current_time = datetime.now(pytz.UTC)
486
+ all_records = []
487
+
488
+ # Get actual date range from the data
489
+ all_dates = data.index.tolist()
490
+ start_date = min(all_dates).date() if all_dates else datetime.now().date()
491
+ end_date = max(all_dates).date() if all_dates else datetime.now().date()
492
+
493
+ # Handle both single ticker and multi-ticker cases
494
+ if len(ticker_list) == 1:
495
+ # Single ticker case - data is not grouped
496
+ ticker = ticker_list[0]
497
+ for date_idx, row in data.iterrows():
498
+ if pd.isna(row['Close']):
499
+ continue
500
+
501
+ trade_date = date_idx.date()
502
+ record = {
503
+ 'ticker': ticker,
504
+ 'date': trade_date,
505
+ 'open': float(row['Open']),
506
+ 'high': float(row['High']),
507
+ 'low': float(row['Low']),
508
+ 'close': float(row['Close']),
509
+ 'volume': int(row['Volume']),
510
+ 'created_at': current_time
511
+ }
512
+ all_records.append(record)
513
+ else:
514
+ # Multiple tickers case - data is grouped by ticker
515
+ for ticker in ticker_list:
516
+ if ticker not in data.columns.get_level_values(0):
517
+ self.logger.warning(f"No data found for ticker {ticker}")
518
+ continue
519
+
520
+ ticker_data = data[ticker]
521
+ if ticker_data.empty:
522
+ continue
523
+
524
+ for date_idx, row in ticker_data.iterrows():
525
+ if pd.isna(row['Close']):
526
+ continue
527
+
528
+ trade_date = date_idx.date()
529
+ record = {
530
+ 'ticker': ticker,
531
+ 'date': trade_date,
532
+ 'open': float(row['Open']),
533
+ 'high': float(row['High']),
534
+ 'low': float(row['Low']),
535
+ 'close': float(row['Close']),
536
+ 'volume': int(row['Volume']),
537
+ 'created_at': current_time
538
+ }
539
+ all_records.append(record)
540
+
541
+ # Bulk insert in chunks of 1000 (optimized for MySQL performance)
542
+ chunk_size = 1000
543
+ total_records = len(all_records)
544
+ inserted_count = 0
545
+
546
+ self.logger.info(f"Inserting {total_records} records in chunks of {chunk_size}")
547
+
548
+ for i in range(0, total_records, chunk_size):
549
+ chunk = all_records[i:i + chunk_size]
550
+ chunk_start = time.perf_counter()
551
+
552
+ # Create TickerData objects for bulk insert
553
+ ticker_objects = [TickerData(**record) for record in chunk]
554
+ session.add_all(ticker_objects)
555
+
556
+ chunk_end = time.perf_counter()
557
+ inserted_count += len(chunk)
558
+ self.logger.info(f"DEBUG: Inserted chunk {i//chunk_size + 1}/{(total_records + chunk_size - 1)//chunk_size} ({len(chunk)} records) in {chunk_end - chunk_start:.2f} seconds")
559
+
560
+ # Commit all changes
561
+ commit_start = time.perf_counter()
562
+ await session.commit()
563
+ commit_end = time.perf_counter()
564
+ self.logger.info(f"DEBUG: Database commit completed in {commit_end - commit_start:.2f} seconds")
565
+
566
+ db_end_time = time.perf_counter()
567
+ db_duration = db_end_time - db_start_time
568
+ self.logger.info(f"DEBUG: Database operations completed in {db_duration:.2f} seconds for {inserted_count} records")
569
+
570
+ # Calculate total end-to-end duration
571
+ total_end_time = time.perf_counter()
572
+ total_duration = total_end_time - total_start_time
573
+ self.logger.info(f"DEBUG: Total bulk refresh completed in {total_duration:.2f} seconds (download: {download_duration:.2f}s, database: {db_duration:.2f}s)")
574
+
575
+ self.logger.info(f"Bulk refresh: inserted {inserted_count} records")
576
+ return {
577
+ "created": inserted_count,
578
+ "updated": 0,
579
+ "date_range": {
580
+ "start_date": start_date.isoformat(),
581
+ "end_date": end_date.isoformat()
582
+ }
583
+ }
584
+
585
+ except Exception as e:
586
+ await session.rollback()
587
+ self.logger.error(f"Error in bulk refresh: {e}")
588
+ raise
589
+
590
+ async def download_all_tickers_data(self, session: AsyncSession, ticker_list: Optional[List[str]] = None) -> Dict[str, Any]:
591
+ """
592
+ Download data for all or specified tickers for the last month.
593
+ Uses smart strategy: checks data freshness, if > 24h, clears DB and bulk inserts new data.
594
+ """
595
+ try:
596
+ # Check ticker freshness and update if needed
597
+ if not await self.check_tickers_freshness(session):
598
+ self.logger.info("Tickers are stale, updating...")
599
+ ticker_service = TickerService(self.config)
600
+ await ticker_service.update_tickers_in_db(session, force_refresh=True)
601
+
602
+ # Get tickers to process
603
+ if ticker_list:
604
+ # Validate provided tickers exist in database
605
+ result = await session.execute(
606
+ select(Ticker.ticker).where(Ticker.ticker.in_(ticker_list))
607
+ )
608
+ valid_tickers = [row[0] for row in result.fetchall()]
609
+ invalid_tickers = set(ticker_list) - set(valid_tickers)
610
+ if invalid_tickers:
611
+ self.logger.warning(f"Invalid tickers ignored: {invalid_tickers}")
612
+ tickers_to_process = valid_tickers
613
+ else:
614
+ # Get all tickers from database
615
+ result = await session.execute(select(Ticker.ticker))
616
+ tickers_to_process = [row[0] for row in result.fetchall()]
617
+
618
+ if not tickers_to_process:
619
+ return {
620
+ "tickers_processed": 0,
621
+ "records_created": 0,
622
+ "records_updated": 0,
623
+ "date_range": {"start_date": "", "end_date": ""},
624
+ "message": "No valid tickers found to process"
625
+ }
626
+
627
+ # Check if ticker data is fresh (less than 24h old)
628
+ if await self.check_ticker_data_freshness(session):
629
+ self.logger.info("Ticker data is fresh (less than 24h old), skipping update")
630
+ return {
631
+ "tickers_processed": len(tickers_to_process),
632
+ "records_created": 0,
633
+ "records_updated": 0,
634
+ "date_range": {"start_date": "", "end_date": ""},
635
+ "message": f"Data is fresh, no update needed for {len(tickers_to_process)} tickers"
636
+ }
637
+
638
+ # Data is stale - use bulk refresh strategy
639
+ self.logger.info("Data is stale (>24h old), using bulk refresh strategy")
640
+ result = await self.clear_and_bulk_insert_ticker_data(session, tickers_to_process)
641
+ total_created = result["created"]
642
+ total_updated = result["updated"]
643
+ successful_tickers = len(tickers_to_process)
644
+
645
+ return {
646
+ "tickers_processed": successful_tickers,
647
+ "records_created": total_created,
648
+ "records_updated": total_updated,
649
+ "date_range": result["date_range"],
650
+ "message": f"Successfully processed {successful_tickers} tickers using bulk refresh"
651
+ }
652
+
653
+ except Exception as e:
654
+ self.logger.error(f"Error in download_all_tickers_data: {e}")
655
+ raise
656
+
657
+
658
+ # --- DATABASE ---
659
+
660
+ class Database:
661
+ def __init__(self, config: Config):
662
+ self.config = config
663
+ # Filter out pool params not supported by NullPool
664
+ db_opts = self.config.config.get('database', {}).copy()
665
+ db_opts.pop('pool_size', None)
666
+ db_opts.pop('max_overflow', None)
667
+ self.engine = create_async_engine(
668
+ config.database_url,
669
+ pool_pre_ping=True,
670
+ poolclass=NullPool,
671
+ **db_opts
672
+ )
673
+ self.async_session = async_sessionmaker(
674
+ self.engine,
675
+ class_=AsyncSession,
676
+ expire_on_commit=False
677
+ )
678
+
679
+ async def create_tables(self):
680
+ async with self.engine.begin() as conn:
681
+ await conn.run_sync(Base.metadata.create_all)
682
+
683
+
684
+ # --- TASK MANAGER ---
685
+
686
+ from sqlalchemy import JSON as SQLAlchemyJSON
687
+
688
+ class Task(Base):
689
+ __tablename__ = "tasks"
690
+ task_id: Mapped[str] = mapped_column(String(64), primary_key=True)
691
+ status: Mapped[str] = mapped_column(String(32), nullable=False)
692
+ message: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
693
+ result: Mapped[Optional[dict]] = mapped_column(SQLAlchemyJSON, nullable=True)
694
+ created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
695
+
696
+ class TaskManager:
697
+ def __init__(self, database: Database):
698
+ self.database = database
699
+
700
+ async def create_table_if_not_exists(self):
701
+ async with self.database.engine.begin() as conn:
702
+ await conn.run_sync(Base.metadata.create_all)
703
+
704
+ async def create_task(self, task_id: str) -> TaskStatus:
705
+ async with self.database.async_session() as session:
706
+ now = datetime.utcnow()
707
+ db_task = Task(
708
+ task_id=task_id,
709
+ status="pending",
710
+ message=None,
711
+ result=None,
712
+ created_at=now
713
+ )
714
+ session.add(db_task)
715
+ await session.commit()
716
+ return TaskStatus(
717
+ task_id=task_id,
718
+ status="pending",
719
+ message=None,
720
+ result=None,
721
+ created_at=now
722
+ )
723
+
724
+ async def update_task(self, task_id: str, status: str, message: str = None, result: Dict = None):
725
+ def serialize_datetimes(obj):
726
+ if isinstance(obj, dict):
727
+ return {k: serialize_datetimes(v) for k, v in obj.items()}
728
+ elif isinstance(obj, list):
729
+ return [serialize_datetimes(v) for v in obj]
730
+ elif isinstance(obj, datetime):
731
+ return obj.isoformat()
732
+ else:
733
+ return obj
734
+
735
+ async with self.database.async_session() as session:
736
+ db_task = await session.get(Task, task_id)
737
+ if db_task:
738
+ db_task.status = status
739
+ db_task.message = message
740
+ db_task.result = serialize_datetimes(result) if result is not None else None
741
+ await session.commit()
742
+
743
+ async def get_task(self, task_id: str) -> Optional[TaskStatus]:
744
+ async with self.database.async_session() as session:
745
+ db_task = await session.get(Task, task_id)
746
+ if db_task:
747
+ return TaskStatus(
748
+ task_id=db_task.task_id,
749
+ status=db_task.status,
750
+ message=db_task.message,
751
+ result=db_task.result,
752
+ created_at=db_task.created_at
753
+ )
754
+ return None
755
+
756
+ async def list_tasks(self) -> list[TaskStatus]:
757
+ async with self.database.async_session() as session:
758
+ result = await session.execute(select(Task))
759
+ tasks = result.scalars().all()
760
+ return [
761
+ TaskStatus(
762
+ task_id=t.task_id,
763
+ status=t.status,
764
+ message=t.message,
765
+ result=t.result,
766
+ created_at=t.created_at
767
+ ) for t in tasks
768
+ ]
769
+
770
+ async def delete_old_tasks(self, older_than_seconds: int = 3600) -> int:
771
+ cutoff = datetime.utcnow() - timedelta(seconds=older_than_seconds)
772
+ async with self.database.async_session() as session:
773
+ result = await session.execute(select(Task).where(Task.created_at < cutoff))
774
+ old_tasks = result.scalars().all()
775
+ count = len(old_tasks)
776
+ for t in old_tasks:
777
+ await session.delete(t)
778
+ await session.commit()
779
+ return count
780
+
781
+
782
+ # --- APP SETUP ---
783
+
784
+ # Global instances
785
+ config = Config()
786
+ database = Database(config)
787
+ ticker_service = TickerService(config)
788
+ yfinance_service = YFinanceService(config)
789
+ task_manager = TaskManager(database)
790
+
791
+ # Dependency function
792
+ async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
793
+ async with database.async_session() as session:
794
+ yield session
795
+
796
+ @asynccontextmanager
797
+ async def lifespan(app: FastAPI):
798
+ # Startup
799
+ await database.create_tables()
800
+ await task_manager.create_table_if_not_exists()
801
+ logging.info("Database tables created/verified")
802
+ yield
803
+ # Shutdown
804
+ await database.engine.dispose()
805
+ logging.info("Database connections closed")
806
+
807
+ # Create FastAPI app
808
+ app = FastAPI(
809
+ title="Stock Monitoring API",
810
+ description="API for managing S&P 500 and Nasdaq 100 ticker data",
811
+ version="0.1.0",
812
+ lifespan=lifespan,
813
+ swagger_ui_parameters={"faviconUrl": "/static/favicon.ico"}
814
+ )
815
+
816
+ # Serve static files (make sure a 'static' folder exists at project root with favicon.ico inside)
817
+ app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static")
818
+
819
+ # Favicon endpoint
820
+ @app.api_route("/favicon.ico", methods=["GET", "HEAD"], include_in_schema=False)
821
+ async def favicon():
822
+ return FileResponse(os.path.join(os.path.dirname(__file__), "static", "favicon.ico"))
823
+
824
+
825
+
826
+
827
+ # --- API ENDPOINTS ---
828
+
829
+ @app.get("/")
830
+ async def root_info():
831
+ """
832
+ Get API health status, current timestamp, versions, and DB/tables check.
833
+
834
+ **Logic**:
835
+ - Returns a JSON object with:
836
+ - **status**: Health status of the API
837
+ - **timestamp**: Current time in UTC timezone
838
+ - **versions**: Dictionary with Python and main library versions
839
+ - **database**: Connection status and existence of 'tickers' and 'tasks' tables
840
+
841
+ **Args**: None
842
+
843
+ **Example response:**
844
+ ```json
845
+ {
846
+ "status": "healthy",
847
+ "timestamp": "2025-07-19T19:38:26+02:00",
848
+ "versions": { ... },
849
+ "database": {
850
+ "connected": true,
851
+ "tickers_table": true,
852
+ "tasks_table": true
853
+ }
854
+ }
855
+ ```
856
+ """
857
+ now_utc = datetime.now(pytz.UTC)
858
+ versions = {}
859
+ versions["python"] = platform.python_version()
860
+ packages = ["uvicorn", "fastapi", "sqlalchemy", "pandas"]
861
+ for pkg in packages:
862
+ try:
863
+ versions[pkg] = importlib.metadata.version(pkg)
864
+ except Exception:
865
+ versions[pkg] = None
866
+
867
+ db_status = {
868
+ "connected": False,
869
+ "tickers_table": False,
870
+ "tasks_table": False
871
+ }
872
+ db_check_time = None
873
+ start = time.perf_counter()
874
+ try:
875
+ async with database.engine.connect() as conn:
876
+ db_status["connected"] = True
877
+ insp = await conn.run_sync(lambda c: c.dialect.get_table_names(c))
878
+ db_status["tickers_table"] = "tickers" in insp
879
+ db_status["tasks_table"] = "tasks" in insp
880
+ except Exception as e:
881
+ db_status["connected"] = False
882
+ finally:
883
+ db_check_time = time.perf_counter() - start
884
+
885
+ return {
886
+ "status": "healthy" if db_status["connected"] and db_status["tickers_table"] and db_status["tasks_table"] else "degraded",
887
+ "timestamp": now_utc.isoformat(),
888
+ "versions": versions,
889
+ "database": db_status,
890
+ "db_check_seconds": round(db_check_time, 4) if db_check_time is not None else None
891
+ }
892
+
893
+
894
+ @app.get("/tickers", response_model=List[TickerResponse])
895
+ async def get_tickers(
896
+ is_sp500: Optional[bool] = None,
897
+ is_nasdaq: Optional[bool] = None,
898
+ limit: int = 1000,
899
+ session: AsyncSession = Depends(get_db_session)
900
+ ):
901
+ """
902
+ Get all tickers from database with optional filtering.
903
+
904
+ **Logic**:
905
+
906
+ - No parameters: Return all tickers
907
+ - is_sp500=true: Only S&P 500 tickers
908
+ - is_sp500=false: Only NON-S&P 500 tickers
909
+ - is_nasdaq=true: Only Nasdaq 100 tickers
910
+ - is_nasdaq=false: Only NON-Nasdaq 100 tickers
911
+ - Both parameters: Apply AND logic (intersection of conditions)
912
+
913
+ **Args (all optional)**:
914
+
915
+ - **is_sp500** (optional): Filter for S&P 500 membership (true/false/None)
916
+ - **is_nasdaq** (optional): Filter for Nasdaq 100 membership (true/false/None)
917
+ - **limit** (optional): Maximum number of results to return
918
+
919
+ **Examples:**
920
+
921
+ - `GET /tickers` - All tickers
922
+ - `GET /tickers?is_sp500=true` - Only S&P 500
923
+ - `GET /tickers?is_nasdaq=true&is_sp500=false` - Only Nasdaq 100 but not S&P 500
924
+ - `GET /tickers?is_sp500=true&is_nasdaq=false` - S&P 500 but not Nasdaq 100
925
+
926
+ """
927
+ try:
928
+ query = select(Ticker)
929
+
930
+ # Build conditions based on explicit flag values
931
+ conditions = []
932
+
933
+ if is_sp500 is not None:
934
+ if is_sp500:
935
+ conditions.append(Ticker.is_sp500 == 1)
936
+ else:
937
+ conditions.append(Ticker.is_sp500 == 0)
938
+
939
+ if is_nasdaq is not None:
940
+ if is_nasdaq:
941
+ conditions.append(Ticker.is_nasdaq100 == 1)
942
+ else:
943
+ conditions.append(Ticker.is_nasdaq100 == 0)
944
+
945
+ # Apply filtering if we have conditions
946
+ if conditions:
947
+ from sqlalchemy import and_
948
+ query = query.where(and_(*conditions))
949
+
950
+ query = query.limit(limit).order_by(Ticker.ticker)
951
+ result = await session.execute(query)
952
+ tickers = result.scalars().all()
953
+
954
+ return [
955
+ TickerResponse(
956
+ ticker=t.ticker,
957
+ name=t.name,
958
+ sector=t.sector,
959
+ subindustry=t.subindustry,
960
+ is_sp500=bool(t.is_sp500),
961
+ is_nasdaq100=bool(t.is_nasdaq100),
962
+ last_updated=t.last_updated
963
+ )
964
+ for t in tickers
965
+ ]
966
+ except Exception as e:
967
+ logging.error(f"Error fetching tickers: {e}")
968
+ raise HTTPException(status_code=500, detail="Failed to fetch tickers")
969
+
970
+
971
+
972
+ @app.post("/tickers/update", response_model=UpdateTickersResponse)
973
+ async def update_tickers(
974
+ request: UpdateTickersRequest,
975
+ background_tasks: BackgroundTasks,
976
+ session: AsyncSession = Depends(get_db_session),
977
+ api_key: str = Depends(verify_api_key)
978
+ ):
979
+ """
980
+ Update tickers from Wikipedia sources (S&P 500 and Nasdaq 100).
981
+
982
+ **Logic**:
983
+ - Fetches latest tickers from Wikipedia (S&P 500 and Nasdaq 100).
984
+ - Updates the database with the new tickers.
985
+ - Returns summary of update (counts, timestamp).
986
+
987
+ **Args**:
988
+ - **request**: UpdateTickersRequest (force_refresh: bool)
989
+ - **background_tasks**: FastAPI BackgroundTasks (unused)
990
+ - **session**: AsyncSession (DB session, injected)
991
+
992
+ **Example request:**
993
+ ```json
994
+ { "force_refresh": false }
995
+ { "force_refresh": true }
996
+ ```
997
+
998
+ **Example response:**
999
+ ```json
1000
+ {
1001
+ "success": true,
1002
+ "message": "Tickers updated successfully",
1003
+ "total_tickers": 517,
1004
+ "sp500_count": 500,
1005
+ "nasdaq100_count": 100,
1006
+ "updated_at": "2025-07-19T19:38:26+02:00"
1007
+ }
1008
+ ```
1009
+ """
1010
+ try:
1011
+ result = await ticker_service.update_tickers_in_db(session, force_refresh=request.force_refresh)
1012
+ message = result.pop("not_updated_reason", None)
1013
+ if message:
1014
+ return UpdateTickersResponse(
1015
+ success=True,
1016
+ message=message,
1017
+ **result
1018
+ )
1019
+ return UpdateTickersResponse(
1020
+ success=True,
1021
+ message="Tickers updated successfully",
1022
+ **result
1023
+ )
1024
+ except Exception as e:
1025
+ logging.error(f"Error updating tickers: {e}")
1026
+ raise HTTPException(status_code=500, detail=f"Failed to update tickers: {str(e)}")
1027
+
1028
+
1029
+ @app.post("/tickers/update-async")
1030
+ async def update_tickers_async(
1031
+ request: UpdateTickersRequest,
1032
+ background_tasks: BackgroundTasks,
1033
+ api_key: str = Depends(verify_api_key)
1034
+ ):
1035
+ """
1036
+ Start async ticker update task (background).
1037
+
1038
+ **Logic**:
1039
+ - Launches a background task to update tickers from Wikipedia.
1040
+ - Returns a task_id and status for tracking.
1041
+
1042
+ **Args**:
1043
+ - **request**: UpdateTickersRequest (force_refresh: bool)
1044
+
1045
+ **Example request:**
1046
+ ```json
1047
+ { "force_refresh": false }
1048
+ { "force_refresh": true }
1049
+ ```
1050
+
1051
+ **Example response:**
1052
+ ```json
1053
+ {
1054
+ "task_id": "c1a2b3d4-5678-90ab-cdef-1234567890ab",
1055
+ "status": "started"
1056
+ }
1057
+ ```
1058
+ """
1059
+ import uuid
1060
+ task_id = str(uuid.uuid4())
1061
+
1062
+ await task_manager.create_task(task_id)
1063
+
1064
+ async def update_task():
1065
+ try:
1066
+ await task_manager.update_task(task_id, "running", "Updating tickers...")
1067
+ async with database.async_session() as session:
1068
+ result = await ticker_service.update_tickers_in_db(session, force_refresh=request.force_refresh)
1069
+ message = result.pop("not_updated_reason", None)
1070
+ if message:
1071
+ await task_manager.update_task(task_id, "completed", message, result)
1072
+ else:
1073
+ await task_manager.update_task(task_id, "completed", "Update successful", result)
1074
+ except Exception as e:
1075
+ await task_manager.update_task(task_id, "failed", str(e))
1076
+
1077
+ background_tasks.add_task(update_task)
1078
+
1079
+ return {"task_id": task_id, "status": "started"}
1080
+
1081
+
1082
+ @app.get("/tasks", response_model=List[TaskStatus])
1083
+ async def list_all_tasks(api_key: str = Depends(verify_api_key)):
1084
+ """
1085
+ List all background tasks and their status.
1086
+
1087
+ **Logic**:
1088
+ - Returns a list of all tasks created via async update endpoint, with their status and result.
1089
+
1090
+ **Args**: None
1091
+
1092
+ **Example response:**
1093
+ ```json
1094
+ [
1095
+ {
1096
+ "task_id": "c1a2b3d4-5678-90ab-cdef-1234567890ab",
1097
+ "status": "completed",
1098
+ "message": "Tickers updated successfully",
1099
+ "result": {
1100
+ "total_tickers": 517,
1101
+ "sp500_count": 500,
1102
+ "nasdaq100_count": 100,
1103
+ "updated_at": "2025-07-19T19:38:26+02:00"
1104
+ },
1105
+ "created_at": "2025-07-19T19:38:26+02:00"
1106
+ },
1107
+ ...
1108
+ ]
1109
+ ```
1110
+ """
1111
+ return await task_manager.list_tasks()
1112
+
1113
+
1114
+ @app.get("/tasks/{task_id}", response_model=TaskStatus)
1115
+ async def get_task_status(task_id: str, api_key: str = Depends(verify_api_key)):
1116
+ """
1117
+ Get status and result of a background update task by task_id.
1118
+
1119
+ **Logic**:
1120
+ - Returns the status and result of a background update task by task_id.
1121
+ - If not found, returns 404.
1122
+
1123
+ **Args**:
1124
+ - **task_id**: str (UUID of the task)
1125
+
1126
+ **Example response:**
1127
+ ```json
1128
+ {
1129
+ "task_id": "c1a2b3d4-5678-90ab-cdef-1234567890ab",
1130
+ "status": "completed",
1131
+ "message": "Tickers updated successfully",
1132
+ "result": {
1133
+ "total_tickers": 517,
1134
+ "sp500_count": 500,
1135
+ "nasdaq100_count": 100,
1136
+ "updated_at": "2025-07-19T19:38:26+02:00"
1137
+ },
1138
+ "created_at": "2025-07-19T19:38:26+02:00"
1139
+ }
1140
+ ```
1141
+ """
1142
+ task = await task_manager.get_task(task_id)
1143
+ if not task:
1144
+ raise HTTPException(status_code=404, detail="Task not found")
1145
+ return task
1146
+
1147
+ # Endpoint to delete tasks older than 1 hour
1148
+ @app.delete("/tasks/old", response_model=dict)
1149
+ async def delete_old_tasks(api_key: str = Depends(verify_api_key)):
1150
+ """
1151
+ Delete tasks older than 1 hour (3600 seconds).
1152
+
1153
+ **Logic**:
1154
+ - Deletes all tasks in the database older than 1 hour.
1155
+ - Returns the number of deleted tasks.
1156
+
1157
+ **Args**: None
1158
+
1159
+ **Example response:**
1160
+ ```json
1161
+ { "deleted": 5 }
1162
+ ```
1163
+ """
1164
+ deleted_count = await task_manager.delete_old_tasks(older_than_seconds=3600)
1165
+ return {"deleted": deleted_count}
1166
+
1167
+
1168
+
1169
+ @app.post("/data/download-all", response_model=DownloadDataResponse)
1170
+ async def download_all_tickers_data(
1171
+ session: AsyncSession = Depends(get_db_session),
1172
+ api_key: str = Depends(verify_api_key)
1173
+ ):
1174
+ """
1175
+ Download daily ticker data for the last month for ALL tickers in database.
1176
+
1177
+ **Logic**:
1178
+ - Automatically downloads data for all tickers stored in the tickers table
1179
+ - Checks if tickers were updated within the last week, updates if needed
1180
+ - Only downloads if ticker data is older than 24 hours
1181
+ - Downloads daily data for the last 30 days for all available tickers
1182
+ - Uses bulk delete and insert strategy for optimal performance
1183
+ - Returns summary with counts and date range
1184
+
1185
+ **Args**:
1186
+ - **session**: AsyncSession (DB session, injected)
1187
+ - **api_key**: str (API key for authentication, injected)
1188
+
1189
+ **Example request:**
1190
+ ```bash
1191
+ curl -X POST "http://localhost:${PORT}/data/download-all" \
1192
+ -H "Authorization: Bearer your_api_key"
1193
+ ```
1194
+
1195
+ **Example response:**
1196
+ ```json
1197
+ {
1198
+ "success": true,
1199
+ "message": "Successfully processed 503 tickers using bulk refresh",
1200
+ "tickers_processed": 503,
1201
+ "records_created": 12075,
1202
+ "records_updated": 0,
1203
+ "date_range": {
1204
+ "start_date": "2025-06-30",
1205
+ "end_date": "2025-07-30"
1206
+ },
1207
+ "updated_at": "2025-07-30T14:15:26+00:00"
1208
+ }
1209
+ ```
1210
+ """
1211
+ try:
1212
+ # Use existing service without specifying ticker list (downloads all)
1213
+ result = await yfinance_service.download_all_tickers_data(
1214
+ session,
1215
+ ticker_list=None # None means download all tickers
1216
+ )
1217
+
1218
+ return DownloadDataResponse(
1219
+ success=True,
1220
+ message=result["message"],
1221
+ tickers_processed=result["tickers_processed"],
1222
+ records_created=result["records_created"],
1223
+ records_updated=result["records_updated"],
1224
+ date_range=result["date_range"],
1225
+ updated_at=datetime.now(pytz.UTC)
1226
+ )
1227
+
1228
+ except Exception as e:
1229
+ logging.error(f"Error downloading all ticker data: {e}")
1230
+ raise HTTPException(status_code=500, detail=f"Failed to download all ticker data: {str(e)}")
1231
+
1232
+
1233
+
1234
+ @app.get("/data/tickers/{ticker}", response_model=List[TickerDataResponse])
1235
+ async def get_ticker_data(
1236
+ ticker: str,
1237
+ days: int = 30,
1238
+ session: AsyncSession = Depends(get_db_session)
1239
+ ):
1240
+ """
1241
+ Get historical data for a specific ticker.
1242
+
1243
+ **Logic**:
1244
+ - Returns historical data for the specified ticker
1245
+ - Defaults to last 30 days if no days parameter provided
1246
+ - Data is ordered by date descending (most recent first)
1247
+
1248
+ **Args**:
1249
+ - **ticker**: str (Ticker symbol, e.g., "AAPL")
1250
+ - **days**: int (Number of days to retrieve, default 30)
1251
+ - **session**: AsyncSession (DB session, injected)
1252
+
1253
+ **Example response:**
1254
+ ```json
1255
+ [
1256
+ {
1257
+ "ticker": "AAPL",
1258
+ "date": "2025-07-30",
1259
+ "open": 150.25,
1260
+ "high": 152.80,
1261
+ "low": 149.50,
1262
+ "close": 151.75,
1263
+ "volume": 45123000,
1264
+ "created_at": "2025-07-30T13:45:26+00:00"
1265
+ }
1266
+ ]
1267
+ ```
1268
+ """
1269
+ try:
1270
+ # Calculate date range
1271
+ end_date = datetime_date.today()
1272
+ start_date = end_date - timedelta(days=days)
1273
+
1274
+ # Query ticker data
1275
+ query = select(TickerData).where(
1276
+ TickerData.ticker == ticker.upper(),
1277
+ TickerData.date >= start_date,
1278
+ TickerData.date <= end_date
1279
+ ).order_by(TickerData.date.desc())
1280
+
1281
+ result = await session.execute(query)
1282
+ ticker_data = result.scalars().all()
1283
+
1284
+ if not ticker_data:
1285
+ raise HTTPException(
1286
+ status_code=404,
1287
+ detail=f"No data found for ticker {ticker.upper()} in the last {days} days"
1288
+ )
1289
+
1290
+ return [
1291
+ TickerDataResponse(
1292
+ ticker=data.ticker,
1293
+ date=data.date,
1294
+ open=data.open,
1295
+ high=data.high,
1296
+ low=data.low,
1297
+ close=data.close,
1298
+ volume=data.volume,
1299
+ created_at=data.created_at
1300
+ )
1301
+ for data in ticker_data
1302
+ ]
1303
+
1304
+ except HTTPException:
1305
+ raise
1306
+ except Exception as e:
1307
+ logging.error(f"Error fetching ticker data for {ticker}: {e}")
1308
+ raise HTTPException(status_code=500, detail="Failed to fetch ticker data")
1309
+
1310
+
1311
+ # Local execution configuration
1312
+ if __name__ == "__main__":
1313
+ import uvicorn
1314
+
1315
+ HOST = os.getenv("HOST", "0.0.0.0")
1316
+ PORT = int(os.getenv("PORT", 8000))
1317
+
1318
+ # Determina el valor de reload segΓΊn la variable de entorno PROD
1319
+ RELOAD = os.getenv("PROD", "False") != "True"
1320
+
1321
+ # Start the Uvicorn server
1322
+ uvicorn.run("index:app", host=HOST, port=PORT, reload=RELOAD)
config.yaml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Configuration file for Market Data API
2
+
3
+ logging:
4
+ level: INFO
5
+ log_file: data_cache/api.log
6
+
7
+ data_sources:
8
+ sp500:
9
+ url: "https://en.wikipedia.org/wiki/List_of_S%26P_500_companies"
10
+ ticker_column: "Symbol"
11
+ name_column: "Security"
12
+ sector_column: "GICS Sector"
13
+ subindustry_column: "GICS Sub-Industry"
14
+
15
+
16
+ nasdaq100:
17
+ url: "https://en.wikipedia.org/wiki/Nasdaq-100"
18
+ ticker_column: "Ticker"
19
+ name_column: "Company"
20
+ sector_column: "GICS Sector"
21
+ subindustry_column: "GICS Sub-Industry"
22
+
23
+ database:
24
+ pool_size: 5
25
+ max_overflow: 10
26
+ echo: false # Set to true for SQL query debugging
requirements.txt ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Requiered Python 3.12
2
+
3
+ # FastAPI Server Requirements
4
+
5
+ # Core frameworks
6
+ fastapi>=0.104.0
7
+ uvicorn[standard]>=0.24.0
8
+
9
+
10
+ # Database
11
+ sqlalchemy[asyncio]>=2.0.0
12
+ aiomysql>=0.2.0
13
+ pymysql>=1.1.0
14
+
15
+ # Data processing
16
+ pandas>=2.0.0
17
+ aiohttp>=3.9.0
18
+ lxml>=4.9.0 # Required for pandas.read_html()
19
+ html5lib>=1.1 # Alternative parser for pandas.read_html()
20
+ yfinance>=0.2.0 # Yahoo Finance data
21
+
22
+ # Configuration and environment
23
+ pydantic>=2.5.0
24
+ python-dotenv>=1.0.0
25
+ PyYAML>=6.0
26
+
27
+ # Logging and utilities
28
+ pytz>=2023.3
29
+
30
+ # Optional: For development
31
+ pytest>=7.4.0
32
+ pytest-asyncio>=0.21.0
33
+ httpx>=0.25.0 # For testing FastAPI
tests/run_tests.sh ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # API Testing Script Runner
4
+ # Make sure the API server is running before executing this script
5
+
6
+ echo "πŸš€ Stock Monitoring API Test Suite"
7
+ echo "=================================="
8
+
9
+ # Load PORT from .env file - simplified approach
10
+ if [ -f "../.env" ]; then
11
+ # Extract PORT specifically from .env
12
+ PORT_LINE=$(grep "^PORT" ../.env | head -1)
13
+ if [ -n "$PORT_LINE" ]; then
14
+ # Extract value after = and clean it up
15
+ PORT=$(echo "$PORT_LINE" | cut -d'=' -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/^"//;s/"$//')
16
+ fi
17
+ echo "βœ… Loaded environment variables from .env"
18
+ else
19
+ echo "⚠️ .env file not found, using default values"
20
+ fi
21
+
22
+ # Use PORT from .env or default to 8000
23
+ PORT=${PORT:-8000}
24
+ echo "πŸ”§ Using PORT: $PORT"
25
+
26
+ # Check if server is running
27
+ echo "πŸ” Checking if API server is running..."
28
+ if curl -s http://localhost:$PORT/ > /dev/null; then
29
+ echo "βœ… API server is running on localhost:$PORT"
30
+ else
31
+ echo "❌ API server is not running!"
32
+ echo "Please start the server first:"
33
+ echo " cd .."
34
+ echo " source .venv/bin/activate"
35
+ echo " python api/index.py"
36
+ exit 1
37
+ fi
38
+
39
+ # Check if virtual environment is activated
40
+ if [[ "$VIRTUAL_ENV" != "" ]]; then
41
+ echo "βœ… Virtual environment is active: $VIRTUAL_ENV"
42
+ else
43
+ echo "⚠️ Virtual environment not detected"
44
+ echo "Consider running: source .venv/bin/activate"
45
+ fi
46
+
47
+ # Install test dependencies if needed
48
+ echo "πŸ“¦ Installing test dependencies..."
49
+ pip install requests python-dotenv > /dev/null 2>&1
50
+
51
+ # Run the tests
52
+ echo "πŸ§ͺ Running API tests..."
53
+ python test_api.py
54
+
55
+ echo ""
56
+ echo "πŸ“ Test completed! Check the output above for results."
tests/test_api.py ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Automated API Testing Script for Stock Monitoring API
4
+ Tests authentication, endpoints, and security features.
5
+
6
+ Updated for new API architecture:
7
+ - Removed /data/download endpoint (now uses only /data/download-all)
8
+ - Removed force_refresh parameter (uses automatic 24h freshness check)
9
+ - Updated bulk download strategy testing
10
+ """
11
+
12
+ import requests
13
+ import json
14
+ import time
15
+ import os
16
+ from dotenv import load_dotenv
17
+
18
+ # Load environment variables from parent directory
19
+ load_dotenv(dotenv_path="../.env")
20
+ PORT = os.getenv("PORT", "8000")
21
+ print(f"Using PORT: {PORT}")
22
+
23
+
24
+ # Configuration
25
+ BASE_URL = f"http://localhost:{PORT}"
26
+ API_KEY = os.getenv("API_KEY")
27
+ INVALID_API_KEY = "invalid_key_for_testing"
28
+
29
+ # Headers
30
+ HEADERS_NO_AUTH = {"Content-Type": "application/json"}
31
+ HEADERS_VALID_AUTH = {
32
+ "Content-Type": "application/json",
33
+ "Authorization": f"Bearer {API_KEY}"
34
+ }
35
+ HEADERS_INVALID_AUTH = {
36
+ "Content-Type": "application/json",
37
+ "Authorization": f"Bearer {INVALID_API_KEY}"
38
+ }
39
+
40
+ def print_test_header(test_name):
41
+ """Print formatted test header."""
42
+ print(f"\n{'='*60}")
43
+ print(f"πŸ§ͺ {test_name}")
44
+ print(f"{'='*60}")
45
+
46
+ def print_result(endpoint, method, expected_status, actual_status, passed):
47
+ """Print test result."""
48
+ status_icon = "βœ…" if passed else "❌"
49
+ print(f"{status_icon} {method} {endpoint}")
50
+ print(f" Expected: {expected_status}, Got: {actual_status}")
51
+ if not passed:
52
+ print(f" ❌ TEST FAILED")
53
+ return passed
54
+
55
+ def test_health_check():
56
+ """Test the health check endpoint (should be public)."""
57
+ print_test_header("Health Check (Public Endpoint)")
58
+
59
+ try:
60
+ response = requests.get(f"{BASE_URL}/", headers=HEADERS_NO_AUTH, timeout=10)
61
+ passed = response.status_code == 200
62
+ print_result("/", "GET", 200, response.status_code, passed)
63
+
64
+ if passed:
65
+ data = response.json()
66
+ print(f" πŸ“Š Status: {data.get('status')}")
67
+ print(f" πŸ• Timestamp: {data.get('timestamp')}")
68
+ print(f" πŸ’Ύ DB Connected: {data.get('database', {}).get('connected')}")
69
+
70
+ return passed
71
+ except Exception as e:
72
+ print(f"❌ Health check failed: {e}")
73
+ return False
74
+
75
+ def test_public_endpoints():
76
+ """Test public endpoints that should work without authentication."""
77
+ print_test_header("Public Endpoints (No Auth Required)")
78
+
79
+ all_passed = True
80
+
81
+ # Test GET /tickers
82
+ try:
83
+ response = requests.get(f"{BASE_URL}/tickers?limit=5", headers=HEADERS_NO_AUTH, timeout=10)
84
+ passed = response.status_code == 200
85
+ all_passed &= print_result("/tickers", "GET", 200, response.status_code, passed)
86
+
87
+ if passed:
88
+ data = response.json()
89
+ print(f" πŸ“ˆ Returned {len(data)} tickers")
90
+ except Exception as e:
91
+ print(f"❌ GET /tickers failed: {e}")
92
+ all_passed = False
93
+
94
+ return all_passed
95
+
96
+ def test_protected_endpoints_no_auth():
97
+ """Test protected endpoints without authentication (should fail)."""
98
+ print_test_header("Protected Endpoints - No Auth (Should Fail)")
99
+
100
+ all_passed = True
101
+
102
+ protected_endpoints = [
103
+ ("POST", "/tickers/update", {"force_refresh": False}),
104
+ ("POST", "/tickers/update-async", {"force_refresh": False}),
105
+ ("POST", "/data/download-all", None),
106
+ ("GET", "/tasks", None),
107
+ ("DELETE", "/tasks/old", None)
108
+ ]
109
+
110
+ for method, endpoint, payload in protected_endpoints:
111
+ try:
112
+ if method == "GET":
113
+ response = requests.get(f"{BASE_URL}{endpoint}", headers=HEADERS_NO_AUTH, timeout=10)
114
+ elif method == "POST":
115
+ response = requests.post(f"{BASE_URL}{endpoint}", headers=HEADERS_NO_AUTH, json=payload, timeout=10)
116
+ elif method == "DELETE":
117
+ response = requests.delete(f"{BASE_URL}{endpoint}", headers=HEADERS_NO_AUTH, timeout=10)
118
+
119
+ # Should return 403 (Forbidden) or 401 (Unauthorized)
120
+ passed = response.status_code in [401, 403]
121
+ all_passed &= print_result(endpoint, method, "401/403", response.status_code, passed)
122
+
123
+ except Exception as e:
124
+ print(f"❌ {method} {endpoint} failed: {e}")
125
+ all_passed = False
126
+
127
+ return all_passed
128
+
129
+ def test_protected_endpoints_invalid_auth():
130
+ """Test protected endpoints with invalid authentication (should fail)."""
131
+ print_test_header("Protected Endpoints - Invalid Auth (Should Fail)")
132
+
133
+ all_passed = True
134
+
135
+ protected_endpoints = [
136
+ ("POST", "/tickers/update", {"force_refresh": False}),
137
+ ("POST", "/data/download-all", None),
138
+ ("GET", "/tasks", None),
139
+ ]
140
+
141
+ for method, endpoint, payload in protected_endpoints:
142
+ try:
143
+ if method == "GET":
144
+ response = requests.get(f"{BASE_URL}{endpoint}", headers=HEADERS_INVALID_AUTH, timeout=10)
145
+ elif method == "POST":
146
+ response = requests.post(f"{BASE_URL}{endpoint}", headers=HEADERS_INVALID_AUTH, json=payload, timeout=10)
147
+
148
+ # Should return 401 (Unauthorized)
149
+ passed = response.status_code == 401
150
+ all_passed &= print_result(endpoint, method, "401", response.status_code, passed)
151
+
152
+ except Exception as e:
153
+ print(f"❌ {method} {endpoint} failed: {e}")
154
+ all_passed = False
155
+
156
+ return all_passed
157
+
158
+ def test_protected_endpoints_valid_auth():
159
+ """Test protected endpoints with valid authentication (should succeed)."""
160
+ print_test_header("Protected Endpoints - Valid Auth (Should Succeed)")
161
+
162
+ all_passed = True
163
+
164
+ # Test GET /tasks
165
+ try:
166
+ response = requests.get(f"{BASE_URL}/tasks", headers=HEADERS_VALID_AUTH, timeout=10)
167
+ passed = response.status_code == 200
168
+ all_passed &= print_result("/tasks", "GET", 200, response.status_code, passed)
169
+
170
+ if passed:
171
+ data = response.json()
172
+ print(f" πŸ“‹ Found {len(data)} tasks")
173
+ except Exception as e:
174
+ print(f"❌ GET /tasks failed: {e}")
175
+ all_passed = False
176
+
177
+ # Test POST /tickers/update-async (safer than sync version)
178
+ try:
179
+ response = requests.post(
180
+ f"{BASE_URL}/tickers/update-async",
181
+ headers=HEADERS_VALID_AUTH,
182
+ json={"force_refresh": False},
183
+ timeout=15
184
+ )
185
+ passed = response.status_code == 200
186
+ all_passed &= print_result("/tickers/update-async", "POST", 200, response.status_code, passed)
187
+
188
+ if passed:
189
+ data = response.json()
190
+ task_id = data.get("task_id")
191
+ print(f" πŸš€ Task started: {task_id}")
192
+
193
+ # Test GET /tasks/{task_id}
194
+ if task_id:
195
+ time.sleep(1) # Give task a moment to start
196
+ response = requests.get(f"{BASE_URL}/tasks/{task_id}", headers=HEADERS_VALID_AUTH, timeout=10)
197
+ passed = response.status_code == 200
198
+ all_passed &= print_result(f"/tasks/{task_id}", "GET", 200, response.status_code, passed)
199
+
200
+ if passed:
201
+ task_data = response.json()
202
+ print(f" πŸ“Š Task status: {task_data.get('status')}")
203
+
204
+ except Exception as e:
205
+ print(f"❌ POST /tickers/update-async failed: {e}")
206
+ all_passed = False
207
+
208
+ # Test DELETE /tasks/old
209
+ try:
210
+ response = requests.delete(f"{BASE_URL}/tasks/old", headers=HEADERS_VALID_AUTH, timeout=10)
211
+ passed = response.status_code == 200
212
+ all_passed &= print_result("/tasks/old", "DELETE", 200, response.status_code, passed)
213
+
214
+ if passed:
215
+ data = response.json()
216
+ print(f" πŸ—‘οΈ Deleted {data.get('deleted', 0)} old tasks")
217
+ except Exception as e:
218
+ print(f"❌ DELETE /tasks/old failed: {e}")
219
+ all_passed = False
220
+
221
+ return all_passed
222
+
223
+ def test_data_endpoints():
224
+ """Test data download and query endpoints."""
225
+ print_test_header("Data Endpoints - Valid Auth (Should Succeed)")
226
+
227
+ all_passed = True
228
+
229
+ # Test POST /data/download-all (bulk download with automatic freshness check)
230
+ # Note: This endpoint now automatically checks if data is <24h old and skips update if fresh
231
+ # First run will download all data, subsequent runs may return "data is fresh" message
232
+ try:
233
+ response = requests.post(
234
+ f"{BASE_URL}/data/download-all",
235
+ headers=HEADERS_VALID_AUTH,
236
+ timeout=90 # Bulk download might take longer, especially first time
237
+ )
238
+ passed = response.status_code == 200
239
+ all_passed &= print_result("/data/download-all", "POST", 200, response.status_code, passed)
240
+
241
+ if passed:
242
+ data = response.json()
243
+ print(f" πŸ“Š Processed {data.get('tickers_processed', 0)} tickers")
244
+ print(f" πŸ“ˆ Created {data.get('records_created', 0)} records")
245
+ print(f" πŸ”„ Updated {data.get('records_updated', 0)} records")
246
+ print(f" πŸ“… Date range: {data.get('date_range', {}).get('start_date')} to {data.get('date_range', {}).get('end_date')}")
247
+ print(f" πŸ’¬ Message: {data.get('message', 'N/A')}")
248
+ except Exception as e:
249
+ print(f"❌ POST /data/download-all failed: {e}")
250
+ all_passed = False
251
+
252
+ # Test GET /data/tickers/{ticker} (public endpoint)
253
+ try:
254
+ response = requests.get(f"{BASE_URL}/data/tickers/AAPL?days=5", headers=HEADERS_NO_AUTH, timeout=10)
255
+ passed = response.status_code == 200
256
+ all_passed &= print_result("/data/tickers/AAPL", "GET", 200, response.status_code, passed)
257
+
258
+ if passed:
259
+ data = response.json()
260
+ print(f" πŸ“Š Retrieved {len(data)} days of AAPL data")
261
+ if data:
262
+ latest = data[0]
263
+ print(f" πŸ’° Latest close: ${latest.get('close', 0):.2f}")
264
+ except Exception as e:
265
+ print(f"❌ GET /data/tickers/AAPL failed: {e}")
266
+ all_passed = False
267
+
268
+ return all_passed
269
+
270
+ def test_sql_injection_safety():
271
+ """Test that SQL injection attempts are safely handled."""
272
+ print_test_header("SQL Injection Safety Tests")
273
+
274
+ all_passed = True
275
+
276
+ # Test various SQL injection attempts in query parameters
277
+ injection_attempts = [
278
+ "'; DROP TABLE tickers; --",
279
+ "' OR '1'='1",
280
+ "1' UNION SELECT * FROM tasks --",
281
+ "'; DELETE FROM tasks; --"
282
+ ]
283
+
284
+ for injection in injection_attempts:
285
+ try:
286
+ # Test in ticker endpoint (should be safely parameterized)
287
+ response = requests.get(
288
+ f"{BASE_URL}/tickers",
289
+ params={"limit": injection},
290
+ headers=HEADERS_NO_AUTH,
291
+ timeout=10
292
+ )
293
+
294
+ # Should either return 422 (validation error) or 200 with safe handling
295
+ passed = response.status_code in [200, 422]
296
+ print_result(f"/tickers?limit={injection[:20]}...", "GET", "200/422", response.status_code, passed)
297
+ all_passed &= passed
298
+
299
+ except Exception as e:
300
+ print(f"❌ SQL injection test failed: {e}")
301
+ all_passed = False
302
+
303
+ print(" πŸ›‘οΈ SQL injection tests completed")
304
+ return all_passed
305
+
306
+ def main():
307
+ """Run all tests."""
308
+ print("πŸ§ͺ Starting Stock Monitoring API Tests")
309
+ print(f"πŸ”— Base URL: {BASE_URL}")
310
+ print(f"πŸ”‘ API Key: {API_KEY[:10]}...")
311
+
312
+ all_tests_passed = True
313
+
314
+ # Run test suites
315
+ all_tests_passed &= test_health_check()
316
+ all_tests_passed &= test_public_endpoints()
317
+ all_tests_passed &= test_protected_endpoints_no_auth()
318
+ all_tests_passed &= test_protected_endpoints_invalid_auth()
319
+ all_tests_passed &= test_protected_endpoints_valid_auth()
320
+ all_tests_passed &= test_data_endpoints()
321
+ all_tests_passed &= test_sql_injection_safety()
322
+
323
+ # Final results
324
+ print(f"\n{'='*60}")
325
+ if all_tests_passed:
326
+ print("πŸŽ‰ ALL TESTS PASSED! βœ…")
327
+ print("βœ… API Key authentication is working")
328
+ print("βœ… Protected endpoints are secure")
329
+ print("βœ… SQL injection protection is active")
330
+ print("βœ… Public endpoints are accessible")
331
+ print("βœ… Bulk data download with freshness check is working")
332
+ print("βœ… New optimized API architecture is functional")
333
+ else:
334
+ print("❌ SOME TESTS FAILED!")
335
+ print("⚠️ Please check the API implementation")
336
+ print(f"{'='*60}")
337
+
338
+ return 0 if all_tests_passed else 1
339
+
340
+ if __name__ == "__main__":
341
+ exit(main())