| """ |
| Tests for the Quantum Hybrid Portfolio API endpoints. |
| Uses Flask test client. Mocks external calls (yfinance) to avoid network dependencies. |
| API_KEY should be unset so auth is bypassed. |
| """ |
| import os |
| import sys |
|
|
| |
| PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) |
| if PROJECT_ROOT not in sys.path: |
| sys.path.insert(0, PROJECT_ROOT) |
|
|
| |
| os.environ.pop('API_KEY', None) |
|
|
| import pytest |
| import numpy as np |
| from unittest.mock import patch, MagicMock |
|
|
| |
| from api import app, generate_mock_data |
|
|
|
|
| |
|
|
| @pytest.fixture |
| def client(): |
| """Flask test client.""" |
| app.config['TESTING'] = True |
| app.config['RATELIMIT_ENABLED'] = False |
| with app.test_client() as c: |
| yield c |
|
|
|
|
| def _minimal_optimize_payload(): |
| """Minimal valid payload for POST /api/portfolio/optimize using returns/covariance.""" |
| n_assets = 5 |
| assets, corr = generate_mock_data(n_assets, 'normal') |
| vols = np.array([a['ann_vol'] for a in assets]) |
| returns = np.array([a['ann_return'] for a in assets]) |
| covariance = np.outer(vols, vols) * corr |
| return { |
| 'returns': returns.tolist(), |
| 'covariance': covariance.tolist(), |
| } |
|
|
|
|
| def _mock_market_data_response(tickers=None): |
| """Return mock market data in the format expected by the API.""" |
| tickers = tickers or ['AAPL', 'MSFT', 'GOOGL'] |
| n = len(tickers) |
| returns = [0.08 + i * 0.02 for i in range(n)] |
| cov = np.eye(n) * 0.04 + np.ones((n, n)) * 0.01 |
| return { |
| 'assets': tickers, |
| 'names': [f'Company {t}' for t in tickers], |
| 'sectors': ['Technology'] * n, |
| 'returns': returns, |
| 'covariance': cov.tolist(), |
| 'start_date': '2023-01-01', |
| 'end_date': '2024-01-01', |
| 'data_points': 252, |
| 'success': True, |
| 'message': f'Successfully fetched data for {n} assets', |
| } |
|
|
|
|
| |
|
|
| def test_health_returns_200_and_json(client): |
| """Test GET /api/health returns 200 and correct JSON.""" |
| resp = client.get('/api/health') |
| assert resp.status_code == 200 |
| data = resp.get_json() |
| assert data is not None |
| assert data.get('status') == 'healthy' |
| assert 'message' in data |
| assert 'Quantum' in data.get('message', '') |
|
|
|
|
| def test_config_objectives_returns_200(client): |
| """Test GET /api/config/objectives returns 200.""" |
| resp = client.get('/api/config/objectives') |
| assert resp.status_code == 200 |
| data = resp.get_json() |
| assert data is not None |
| payload = data.get('data', data) |
| assert 'objectives' in payload |
| assert len(payload['objectives']) > 0 |
| assert any(obj.get('id') == 'markowitz' for obj in payload['objectives']) |
|
|
|
|
| def test_config_presets_returns_200(client): |
| """Test GET /api/config/presets returns 200.""" |
| resp = client.get('/api/config/presets') |
| assert resp.status_code == 200 |
| data = resp.get_json() |
| assert data is not None |
| payload = data.get('data', data) |
| assert 'presets' in payload |
| assert len(payload['presets']) > 0 |
| assert any(p.get('id') == 'default' for p in payload['presets']) |
|
|
|
|
| def test_config_constraints_returns_200(client): |
| """Test GET /api/config/constraints returns 200.""" |
| resp = client.get('/api/config/constraints') |
| assert resp.status_code == 200 |
| data = resp.get_json() |
| assert data is not None |
| assert 'sector_limits' in data or 'cardinality' in data |
|
|
|
|
| @patch('api.fetch_market_data') |
| def test_portfolio_optimize_with_minimal_valid_data(mock_fetch, client): |
| """Test POST /api/portfolio/optimize with minimal valid data via generate_mock_data.""" |
| payload = _minimal_optimize_payload() |
| resp = client.post( |
| '/api/portfolio/optimize', |
| json=payload, |
| content_type='application/json', |
| ) |
| assert resp.status_code == 200 |
| data = resp.get_json() |
| assert data is not None |
| assert 'qsw_result' in data |
| assert 'holdings' in data |
| assert 'benchmarks' in data |
| assert 'weights' in data['qsw_result'] |
| mock_fetch.assert_not_called() |
|
|
|
|
| def test_portfolio_optimize_missing_data_returns_400(client): |
| """Test POST /api/portfolio/optimize with missing data returns 400.""" |
| resp = client.post( |
| '/api/portfolio/optimize', |
| json={}, |
| content_type='application/json', |
| ) |
| assert resp.status_code == 400 |
| data = resp.get_json() |
| assert data is not None |
| assert 'error' in data |
|
|
| |
| resp2 = client.post( |
| '/api/portfolio/optimize', |
| json={'regime': 'normal'}, |
| content_type='application/json', |
| ) |
| assert resp2.status_code == 400 |
|
|
|
|
| def test_market_data_empty_tickers_returns_400(client): |
| """Test POST /api/market-data with invalid/empty tickers returns 400.""" |
| resp = client.post( |
| '/api/market-data', |
| json={'tickers': []}, |
| content_type='application/json', |
| ) |
| assert resp.status_code == 400 |
| data = resp.get_json() |
| assert 'error' in data |
|
|
| |
| resp2 = client.post( |
| '/api/market-data', |
| json={}, |
| content_type='application/json', |
| ) |
| assert resp2.status_code == 400 |
|
|
|
|
| def test_backtest_missing_required_fields_returns_400(client): |
| """Test POST /api/portfolio/backtest with missing required fields returns 400.""" |
| |
| resp = client.post( |
| '/api/portfolio/backtest', |
| json={'start_date': '2023-01-01', 'end_date': '2024-01-01'}, |
| content_type='application/json', |
| ) |
| assert resp.status_code == 400 |
|
|
| |
| resp2 = client.post( |
| '/api/portfolio/backtest', |
| json={'tickers': ['AAPL', 'MSFT']}, |
| content_type='application/json', |
| ) |
| assert resp2.status_code == 400 |
|
|
| |
| resp3 = client.post( |
| '/api/portfolio/backtest', |
| json={ |
| 'tickers': ['AAPL', 'MSFT'], |
| 'start_date': '2024-01-01', |
| 'end_date': '2023-01-01', |
| }, |
| content_type='application/json', |
| ) |
| assert resp3.status_code == 400 |
|
|
|
|
| @patch('api.fetch_market_data') |
| def test_efficient_frontier_with_minimal_valid_data(mock_fetch, client): |
| """Test POST /api/portfolio/efficient-frontier with minimal valid data.""" |
| mock_fetch.return_value = _mock_market_data_response(['AAPL', 'MSFT', 'GOOGL']) |
|
|
| resp = client.post( |
| '/api/portfolio/efficient-frontier', |
| json={ |
| 'tickers': ['AAPL', 'MSFT', 'GOOGL'], |
| 'start_date': '2023-01-01', |
| 'end_date': '2024-01-01', |
| 'n_points': 5, |
| }, |
| content_type='application/json', |
| ) |
| assert resp.status_code == 200 |
| data = resp.get_json() |
| assert data is not None |
| assert 'frontier_points' in data |
| assert 'min_return' in data |
| assert 'max_return' in data |
| assert 'tickers' in data |
| mock_fetch.assert_called_once() |
|
|