mirror of
https://github.com/Xe138/AI-Trader.git
synced 2026-04-01 17:17:24 -04:00
feat: transform to REST API service with SQLite persistence (v0.3.0)
Major architecture transformation from batch-only to API service with
database persistence for Windmill integration.
## REST API Implementation
- POST /simulate/trigger - Start simulation jobs
- GET /simulate/status/{job_id} - Monitor job progress
- GET /results - Query results with filters (job_id, date, model)
- GET /health - Service health checks
## Database Layer
- SQLite persistence with 6 tables (jobs, job_details, positions,
holdings, reasoning_logs, tool_usage)
- Foreign key constraints with cascade deletes
- Replaces JSONL file storage
## Backend Components
- JobManager: Job lifecycle management with concurrency control
- RuntimeConfigManager: Thread-safe isolated runtime configs
- ModelDayExecutor: Single model-day execution engine
- SimulationWorker: Date-sequential, model-parallel orchestration
## Testing
- 102 unit and integration tests (85% coverage)
- Database: 98% coverage
- Job manager: 98% coverage
- API endpoints: 81% coverage
- Pydantic models: 100% coverage
- TDD approach throughout
## Docker Deployment
- Dual-mode: API server (persistent) + batch (one-time)
- Health checks with 30s interval
- Volume persistence for database and logs
- Separate entrypoints for each mode
## Validation Tools
- scripts/validate_docker_build.sh - Build validation
- scripts/test_api_endpoints.sh - Complete API testing
- scripts/test_batch_mode.sh - Batch mode validation
- DOCKER_API.md - Deployment guide
- TESTING_GUIDE.md - Testing procedures
## Configuration
- API_PORT environment variable (default: 8080)
- Backwards compatible with existing configs
- FastAPI, uvicorn, pydantic>=2.0 dependencies
Co-Authored-By: AI Assistant <noreply@example.com>
This commit is contained in:
422
tests/unit/test_job_manager.py
Normal file
422
tests/unit/test_job_manager.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
Unit tests for api/job_manager.py - Job lifecycle management.
|
||||
|
||||
Coverage target: 95%+
|
||||
|
||||
Tests verify:
|
||||
- Job creation and validation
|
||||
- Status transitions (state machine)
|
||||
- Progress tracking
|
||||
- Concurrency control
|
||||
- Job retrieval and queries
|
||||
- Cleanup operations
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestJobCreation:
|
||||
"""Test job creation and validation."""
|
||||
|
||||
def test_create_job_success(self, clean_db):
|
||||
"""Should create job with pending status."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
job_id = manager.create_job(
|
||||
config_path="configs/test.json",
|
||||
date_range=["2025-01-16", "2025-01-17"],
|
||||
models=["gpt-5", "claude-3.7-sonnet"]
|
||||
)
|
||||
|
||||
assert job_id is not None
|
||||
job = manager.get_job(job_id)
|
||||
assert job["status"] == "pending"
|
||||
assert job["date_range"] == ["2025-01-16", "2025-01-17"]
|
||||
assert job["models"] == ["gpt-5", "claude-3.7-sonnet"]
|
||||
assert job["created_at"] is not None
|
||||
|
||||
def test_create_job_with_job_details(self, clean_db):
|
||||
"""Should create job_details for each model-day."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
job_id = manager.create_job(
|
||||
config_path="configs/test.json",
|
||||
date_range=["2025-01-16", "2025-01-17"],
|
||||
models=["gpt-5"]
|
||||
)
|
||||
|
||||
progress = manager.get_job_progress(job_id)
|
||||
assert progress["total_model_days"] == 2 # 2 dates × 1 model
|
||||
assert progress["completed"] == 0
|
||||
assert progress["failed"] == 0
|
||||
|
||||
def test_create_job_blocks_concurrent(self, clean_db):
|
||||
"""Should prevent creating second job while first is pending."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
job1_id = manager.create_job(
|
||||
"configs/test.json",
|
||||
["2025-01-16"],
|
||||
["gpt-5"]
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Another simulation job is already running"):
|
||||
manager.create_job(
|
||||
"configs/test.json",
|
||||
["2025-01-17"],
|
||||
["gpt-5"]
|
||||
)
|
||||
|
||||
def test_create_job_after_completion(self, clean_db):
|
||||
"""Should allow new job after previous completes."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
job1_id = manager.create_job(
|
||||
"configs/test.json",
|
||||
["2025-01-16"],
|
||||
["gpt-5"]
|
||||
)
|
||||
|
||||
manager.update_job_status(job1_id, "completed")
|
||||
|
||||
# Now second job should be allowed
|
||||
job2_id = manager.create_job(
|
||||
"configs/test.json",
|
||||
["2025-01-17"],
|
||||
["gpt-5"]
|
||||
)
|
||||
assert job2_id is not None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestJobStatusTransitions:
|
||||
"""Test job status state machine."""
|
||||
|
||||
def test_pending_to_running(self, clean_db):
|
||||
"""Should transition from pending to running."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
job_id = manager.create_job(
|
||||
"configs/test.json",
|
||||
["2025-01-16"],
|
||||
["gpt-5"]
|
||||
)
|
||||
|
||||
# Update detail to running
|
||||
manager.update_job_detail_status(job_id, "2025-01-16", "gpt-5", "running")
|
||||
|
||||
job = manager.get_job(job_id)
|
||||
assert job["status"] == "running"
|
||||
assert job["started_at"] is not None
|
||||
|
||||
def test_running_to_completed(self, clean_db):
|
||||
"""Should transition to completed when all details complete."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
job_id = manager.create_job(
|
||||
"configs/test.json",
|
||||
["2025-01-16"],
|
||||
["gpt-5"]
|
||||
)
|
||||
|
||||
manager.update_job_detail_status(job_id, "2025-01-16", "gpt-5", "running")
|
||||
manager.update_job_detail_status(job_id, "2025-01-16", "gpt-5", "completed")
|
||||
|
||||
job = manager.get_job(job_id)
|
||||
assert job["status"] == "completed"
|
||||
assert job["completed_at"] is not None
|
||||
assert job["total_duration_seconds"] is not None
|
||||
|
||||
def test_partial_completion(self, clean_db):
|
||||
"""Should mark as partial when some models fail."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
job_id = manager.create_job(
|
||||
"configs/test.json",
|
||||
["2025-01-16"],
|
||||
["gpt-5", "claude-3.7-sonnet"]
|
||||
)
|
||||
|
||||
# First model succeeds
|
||||
manager.update_job_detail_status(job_id, "2025-01-16", "gpt-5", "running")
|
||||
manager.update_job_detail_status(job_id, "2025-01-16", "gpt-5", "completed")
|
||||
|
||||
# Second model fails
|
||||
manager.update_job_detail_status(job_id, "2025-01-16", "claude-3.7-sonnet", "running")
|
||||
manager.update_job_detail_status(
|
||||
job_id, "2025-01-16", "claude-3.7-sonnet", "failed",
|
||||
error="API timeout"
|
||||
)
|
||||
|
||||
job = manager.get_job(job_id)
|
||||
assert job["status"] == "partial"
|
||||
|
||||
progress = manager.get_job_progress(job_id)
|
||||
assert progress["completed"] == 1
|
||||
assert progress["failed"] == 1
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestJobRetrieval:
|
||||
"""Test job query operations."""
|
||||
|
||||
def test_get_nonexistent_job(self, clean_db):
|
||||
"""Should return None for nonexistent job."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
job = manager.get_job("nonexistent-id")
|
||||
assert job is None
|
||||
|
||||
def test_get_current_job(self, clean_db):
|
||||
"""Should return most recent job."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
job1_id = manager.create_job("configs/test.json", ["2025-01-16"], ["gpt-5"])
|
||||
manager.update_job_status(job1_id, "completed")
|
||||
|
||||
job2_id = manager.create_job("configs/test.json", ["2025-01-17"], ["gpt-5"])
|
||||
|
||||
current = manager.get_current_job()
|
||||
assert current["job_id"] == job2_id
|
||||
|
||||
def test_get_current_job_empty(self, clean_db):
|
||||
"""Should return None when no jobs exist."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
current = manager.get_current_job()
|
||||
assert current is None
|
||||
|
||||
def test_find_job_by_date_range(self, clean_db):
|
||||
"""Should find existing job with same date range."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
job_id = manager.create_job(
|
||||
"configs/test.json",
|
||||
["2025-01-16", "2025-01-17"],
|
||||
["gpt-5"]
|
||||
)
|
||||
|
||||
found = manager.find_job_by_date_range(["2025-01-16", "2025-01-17"])
|
||||
assert found["job_id"] == job_id
|
||||
|
||||
def test_find_job_by_date_range_not_found(self, clean_db):
|
||||
"""Should return None when no matching job exists."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
manager.create_job(
|
||||
"configs/test.json",
|
||||
["2025-01-16"],
|
||||
["gpt-5"]
|
||||
)
|
||||
|
||||
found = manager.find_job_by_date_range(["2025-01-20", "2025-01-21"])
|
||||
assert found is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestJobProgress:
|
||||
"""Test job progress tracking."""
|
||||
|
||||
def test_progress_all_pending(self, clean_db):
|
||||
"""Should show 0 completed when all pending."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
job_id = manager.create_job(
|
||||
"configs/test.json",
|
||||
["2025-01-16", "2025-01-17"],
|
||||
["gpt-5"]
|
||||
)
|
||||
|
||||
progress = manager.get_job_progress(job_id)
|
||||
assert progress["total_model_days"] == 2
|
||||
assert progress["completed"] == 0
|
||||
assert progress["failed"] == 0
|
||||
assert progress["current"] is None
|
||||
|
||||
def test_progress_with_running(self, clean_db):
|
||||
"""Should identify currently running model-day."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
job_id = manager.create_job(
|
||||
"configs/test.json",
|
||||
["2025-01-16"],
|
||||
["gpt-5"]
|
||||
)
|
||||
|
||||
manager.update_job_detail_status(job_id, "2025-01-16", "gpt-5", "running")
|
||||
|
||||
progress = manager.get_job_progress(job_id)
|
||||
assert progress["current"] == {"date": "2025-01-16", "model": "gpt-5"}
|
||||
|
||||
def test_progress_details(self, clean_db):
|
||||
"""Should return detailed progress for all model-days."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
job_id = manager.create_job(
|
||||
"configs/test.json",
|
||||
["2025-01-16"],
|
||||
["gpt-5", "claude-3.7-sonnet"]
|
||||
)
|
||||
|
||||
manager.update_job_detail_status(job_id, "2025-01-16", "gpt-5", "completed")
|
||||
|
||||
progress = manager.get_job_progress(job_id)
|
||||
assert len(progress["details"]) == 2
|
||||
|
||||
# Find the gpt-5 detail (order may vary)
|
||||
gpt5_detail = next(d for d in progress["details"] if d["model"] == "gpt-5")
|
||||
assert gpt5_detail["status"] == "completed"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestConcurrencyControl:
|
||||
"""Test concurrency control mechanisms."""
|
||||
|
||||
def test_can_start_new_job_when_empty(self, clean_db):
|
||||
"""Should allow job when none exist."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
assert manager.can_start_new_job() is True
|
||||
|
||||
def test_can_start_new_job_blocks_pending(self, clean_db):
|
||||
"""Should block when job is pending."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
manager.create_job("configs/test.json", ["2025-01-16"], ["gpt-5"])
|
||||
|
||||
assert manager.can_start_new_job() is False
|
||||
|
||||
def test_can_start_new_job_blocks_running(self, clean_db):
|
||||
"""Should block when job is running."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
job_id = manager.create_job("configs/test.json", ["2025-01-16"], ["gpt-5"])
|
||||
manager.update_job_status(job_id, "running")
|
||||
|
||||
assert manager.can_start_new_job() is False
|
||||
|
||||
def test_can_start_new_job_allows_after_completion(self, clean_db):
|
||||
"""Should allow new job after previous completes."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
job_id = manager.create_job("configs/test.json", ["2025-01-16"], ["gpt-5"])
|
||||
manager.update_job_status(job_id, "completed")
|
||||
|
||||
assert manager.can_start_new_job() is True
|
||||
|
||||
def test_get_running_jobs(self, clean_db):
|
||||
"""Should return all running/pending jobs."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
job1_id = manager.create_job("configs/test.json", ["2025-01-16"], ["gpt-5"])
|
||||
|
||||
# Complete first job
|
||||
manager.update_job_status(job1_id, "completed")
|
||||
|
||||
# Create second job
|
||||
job2_id = manager.create_job("configs/test.json", ["2025-01-17"], ["gpt-5"])
|
||||
|
||||
running = manager.get_running_jobs()
|
||||
assert len(running) == 1
|
||||
assert running[0]["job_id"] == job2_id
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestJobCleanup:
|
||||
"""Test maintenance operations."""
|
||||
|
||||
def test_cleanup_old_jobs(self, clean_db):
|
||||
"""Should delete jobs older than threshold."""
|
||||
from api.job_manager import JobManager
|
||||
from api.database import get_db_connection
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
|
||||
# Create old job (manually set created_at)
|
||||
conn = get_db_connection(clean_db)
|
||||
cursor = conn.cursor()
|
||||
|
||||
old_date = (datetime.utcnow() - timedelta(days=35)).isoformat() + "Z"
|
||||
cursor.execute("""
|
||||
INSERT INTO jobs (job_id, config_path, status, date_range, models, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", ("old-job", "configs/test.json", "completed", '["2025-01-01"]', '["gpt-5"]', old_date))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Create recent job
|
||||
recent_id = manager.create_job("configs/test.json", ["2025-01-16"], ["gpt-5"])
|
||||
|
||||
# Cleanup jobs older than 30 days
|
||||
result = manager.cleanup_old_jobs(days=30)
|
||||
|
||||
assert result["jobs_deleted"] == 1
|
||||
assert manager.get_job("old-job") is None
|
||||
assert manager.get_job(recent_id) is not None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestJobUpdateOperations:
|
||||
"""Test job update methods."""
|
||||
|
||||
def test_update_job_status_with_error(self, clean_db):
|
||||
"""Should record error message when job fails."""
|
||||
from api.job_manager import JobManager
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
job_id = manager.create_job("configs/test.json", ["2025-01-16"], ["gpt-5"])
|
||||
|
||||
manager.update_job_status(job_id, "failed", error="MCP service unavailable")
|
||||
|
||||
job = manager.get_job(job_id)
|
||||
assert job["status"] == "failed"
|
||||
assert job["error"] == "MCP service unavailable"
|
||||
|
||||
def test_update_job_detail_records_duration(self, clean_db):
|
||||
"""Should calculate duration for completed model-days."""
|
||||
from api.job_manager import JobManager
|
||||
import time
|
||||
|
||||
manager = JobManager(db_path=clean_db)
|
||||
job_id = manager.create_job("configs/test.json", ["2025-01-16"], ["gpt-5"])
|
||||
|
||||
# Start
|
||||
manager.update_job_detail_status(job_id, "2025-01-16", "gpt-5", "running")
|
||||
|
||||
# Small delay
|
||||
time.sleep(0.1)
|
||||
|
||||
# Complete
|
||||
manager.update_job_detail_status(job_id, "2025-01-16", "gpt-5", "completed")
|
||||
|
||||
progress = manager.get_job_progress(job_id)
|
||||
detail = progress["details"][0]
|
||||
|
||||
assert detail["duration_seconds"] is not None
|
||||
assert detail["duration_seconds"] > 0
|
||||
|
||||
|
||||
# Coverage target: 95%+ for api/job_manager.py
|
||||
Reference in New Issue
Block a user