Files
AI-Trader/tests/unit/test_models.py
Bill fb9583b374 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>
2025-10-31 11:47:10 -04:00

382 lines
11 KiB
Python

"""
Unit tests for api/models.py - Pydantic data models.
Coverage target: 90%+
Tests verify:
- Request model validation
- Response model serialization
- Field constraints and types
- Optional vs required fields
"""
import pytest
from pydantic import ValidationError
from datetime import datetime
@pytest.mark.unit
class TestTriggerSimulationRequest:
"""Test TriggerSimulationRequest model."""
def test_valid_request_with_defaults(self):
"""Should accept request with default config_path."""
from api.models import TriggerSimulationRequest
request = TriggerSimulationRequest()
assert request.config_path == "configs/default_config.json"
def test_valid_request_with_custom_path(self):
"""Should accept request with custom config_path."""
from api.models import TriggerSimulationRequest
request = TriggerSimulationRequest(config_path="configs/custom.json")
assert request.config_path == "configs/custom.json"
@pytest.mark.unit
class TestJobProgress:
"""Test JobProgress model."""
def test_valid_progress_minimal(self):
"""Should create progress with minimal fields."""
from api.models import JobProgress
progress = JobProgress(
total_model_days=4,
completed=2,
failed=0
)
assert progress.total_model_days == 4
assert progress.completed == 2
assert progress.failed == 0
assert progress.current is None
assert progress.details is None
def test_valid_progress_with_current(self):
"""Should include current model-day being executed."""
from api.models import JobProgress
progress = JobProgress(
total_model_days=4,
completed=1,
failed=0,
current={"date": "2025-01-16", "model": "gpt-5"}
)
assert progress.current == {"date": "2025-01-16", "model": "gpt-5"}
def test_valid_progress_with_details(self):
"""Should include detailed progress for all model-days."""
from api.models import JobProgress
details = [
{"date": "2025-01-16", "model": "gpt-5", "status": "completed", "duration_seconds": 45.2},
{"date": "2025-01-16", "model": "claude", "status": "running", "duration_seconds": None}
]
progress = JobProgress(
total_model_days=2,
completed=1,
failed=0,
details=details
)
assert len(progress.details) == 2
assert progress.details[0]["status"] == "completed"
@pytest.mark.unit
class TestTriggerSimulationResponse:
"""Test TriggerSimulationResponse model."""
def test_valid_response_accepted(self):
"""Should create accepted response."""
from api.models import TriggerSimulationResponse
response = TriggerSimulationResponse(
job_id="test-job-123",
status="accepted",
date_range=["2025-01-16", "2025-01-17"],
models=["gpt-5"],
created_at="2025-01-20T14:30:00Z",
message="Job queued successfully"
)
assert response.job_id == "test-job-123"
assert response.status == "accepted"
assert len(response.date_range) == 2
assert response.progress is None
def test_valid_response_with_progress(self):
"""Should include progress for running jobs."""
from api.models import TriggerSimulationResponse, JobProgress
progress = JobProgress(
total_model_days=4,
completed=2,
failed=0
)
response = TriggerSimulationResponse(
job_id="test-job-123",
status="running",
date_range=["2025-01-16"],
models=["gpt-5"],
created_at="2025-01-20T14:30:00Z",
message="Simulation in progress",
progress=progress
)
assert response.progress is not None
assert response.progress.completed == 2
@pytest.mark.unit
class TestJobStatusResponse:
"""Test JobStatusResponse model."""
def test_valid_status_running(self):
"""Should create running status response."""
from api.models import JobStatusResponse, JobProgress
progress = JobProgress(
total_model_days=4,
completed=2,
failed=0,
current={"date": "2025-01-16", "model": "gpt-5"}
)
response = JobStatusResponse(
job_id="test-job-123",
status="running",
date_range=["2025-01-16", "2025-01-17"],
models=["gpt-5", "claude"],
progress=progress,
created_at="2025-01-20T14:30:00Z"
)
assert response.status == "running"
assert response.completed_at is None
assert response.total_duration_seconds is None
def test_valid_status_completed(self):
"""Should create completed status response."""
from api.models import JobStatusResponse, JobProgress
progress = JobProgress(
total_model_days=4,
completed=4,
failed=0
)
response = JobStatusResponse(
job_id="test-job-123",
status="completed",
date_range=["2025-01-16"],
models=["gpt-5"],
progress=progress,
created_at="2025-01-20T14:30:00Z",
completed_at="2025-01-20T14:35:00Z",
total_duration_seconds=300.5
)
assert response.status == "completed"
assert response.completed_at == "2025-01-20T14:35:00Z"
assert response.total_duration_seconds == 300.5
@pytest.mark.unit
class TestDailyPnL:
"""Test DailyPnL model."""
def test_valid_pnl(self):
"""Should create P&L with all fields."""
from api.models import DailyPnL
pnl = DailyPnL(
profit=150.50,
return_pct=1.51,
portfolio_value=10150.50
)
assert pnl.profit == 150.50
assert pnl.return_pct == 1.51
assert pnl.portfolio_value == 10150.50
@pytest.mark.unit
class TestTrade:
"""Test Trade model."""
def test_valid_trade_buy(self):
"""Should create buy trade."""
from api.models import Trade
trade = Trade(
id=1,
action="buy",
symbol="AAPL",
amount=10,
price=255.88,
total=2558.80
)
assert trade.action == "buy"
assert trade.symbol == "AAPL"
assert trade.amount == 10
def test_valid_trade_sell(self):
"""Should create sell trade."""
from api.models import Trade
trade = Trade(
id=2,
action="sell",
symbol="MSFT",
amount=5
)
assert trade.action == "sell"
assert trade.price is None # Optional
assert trade.total is None # Optional
@pytest.mark.unit
class TestAIReasoning:
"""Test AIReasoning model."""
def test_valid_reasoning(self):
"""Should create reasoning summary."""
from api.models import AIReasoning
reasoning = AIReasoning(
total_steps=15,
stop_signal_received=True,
reasoning_summary="Market analysis shows...",
tool_usage={"search": 3, "get_price": 5, "trade": 1}
)
assert reasoning.total_steps == 15
assert reasoning.stop_signal_received is True
assert "search" in reasoning.tool_usage
@pytest.mark.unit
class TestModelResult:
"""Test ModelResult model."""
def test_valid_result_minimal(self):
"""Should create minimal result."""
from api.models import ModelResult, DailyPnL
pnl = DailyPnL(profit=150.0, return_pct=1.5, portfolio_value=10150.0)
result = ModelResult(
model="gpt-5",
positions={"AAPL": 10, "CASH": 7500.0},
daily_pnl=pnl
)
assert result.model == "gpt-5"
assert result.positions["AAPL"] == 10
assert result.trades is None
assert result.ai_reasoning is None
def test_valid_result_full(self):
"""Should create full result with all details."""
from api.models import ModelResult, DailyPnL, Trade, AIReasoning
pnl = DailyPnL(profit=150.0, return_pct=1.5, portfolio_value=10150.0)
trades = [Trade(id=1, action="buy", symbol="AAPL", amount=10)]
reasoning = AIReasoning(
total_steps=15,
stop_signal_received=True,
reasoning_summary="...",
tool_usage={"search": 3}
)
result = ModelResult(
model="gpt-5",
positions={"AAPL": 10, "CASH": 7500.0},
daily_pnl=pnl,
trades=trades,
ai_reasoning=reasoning,
log_file_path="data/agent_data/gpt-5/log/2025-01-16/log.jsonl"
)
assert result.trades is not None
assert len(result.trades) == 1
assert result.ai_reasoning is not None
@pytest.mark.unit
class TestResultsResponse:
"""Test ResultsResponse model."""
def test_valid_results_response(self):
"""Should create results response."""
from api.models import ResultsResponse, ModelResult, DailyPnL
pnl = DailyPnL(profit=150.0, return_pct=1.5, portfolio_value=10150.0)
model_result = ModelResult(
model="gpt-5",
positions={"AAPL": 10, "CASH": 7500.0},
daily_pnl=pnl
)
response = ResultsResponse(
date="2025-01-16",
results=[model_result]
)
assert response.date == "2025-01-16"
assert len(response.results) == 1
assert response.results[0].model == "gpt-5"
@pytest.mark.unit
class TestResultsQueryParams:
"""Test ResultsQueryParams model."""
def test_valid_params_minimal(self):
"""Should create params with minimal fields."""
from api.models import ResultsQueryParams
params = ResultsQueryParams(date="2025-01-16")
assert params.date == "2025-01-16"
assert params.model is None
assert params.detail == "minimal"
def test_valid_params_with_filters(self):
"""Should create params with all filters."""
from api.models import ResultsQueryParams
params = ResultsQueryParams(
date="2025-01-16",
model="gpt-5",
detail="full"
)
assert params.model == "gpt-5"
assert params.detail == "full"
def test_invalid_date_format(self):
"""Should reject invalid date format."""
from api.models import ResultsQueryParams
with pytest.raises(ValidationError):
ResultsQueryParams(date="2025/01/16") # Wrong format
def test_invalid_detail_value(self):
"""Should reject invalid detail value."""
from api.models import ResultsQueryParams
with pytest.raises(ValidationError):
ResultsQueryParams(date="2025-01-16", detail="invalid")
# Coverage target: 90%+ for api/models.py