"""Property-based tests for Trading Engine HTTP API. Feature: autonomous-trading-engine Tests properties 35 and 29 from the design specification, covering configuration change audit trail and persistence round-trip for trading engine state via the FastAPI endpoints. """ from __future__ import annotations from hypothesis import given, settings from hypothesis import strategies as st from starlette.testclient import TestClient from services.trading.app import app # --------------------------------------------------------------------------- # Hypothesis strategies # --------------------------------------------------------------------------- _RISK_TIERS = st.sampled_from(["conservative", "moderate", "aggressive"]) _CONFIG_UPDATES = st.fixed_dictionaries( {}, optional={ "enabled": st.booleans(), "risk_tier": _RISK_TIERS, "reserve_siphon_pct": st.floats( min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False, ), "polling_interval_seconds": st.integers(min_value=1, max_value=3600), "absolute_position_cap": st.floats( min_value=1.0, max_value=10_000.0, allow_nan=False, allow_infinity=False, ), "active_pool_minimum": st.floats( min_value=0.0, max_value=10_000.0, allow_nan=False, allow_infinity=False, ), "micro_trading_enabled": st.booleans(), }, ).filter(lambda d: len(d) > 0) # at least one field must be set # --------------------------------------------------------------------------- # Module-level client with lifespan # --------------------------------------------------------------------------- # Use a stack to manage the TestClient context manager at module scope. _client: TestClient | None = None def _get_client() -> TestClient: global _client if _client is None: _client = TestClient(app, raise_server_exceptions=True) _client.__enter__() return _client def teardown_module() -> None: global _client if _client is not None: _client.__exit__(None, None, None) _client = None # --------------------------------------------------------------------------- # Property 35: Configuration change audit trail # **Validates: Requirements 16.6** # --------------------------------------------------------------------------- class TestProperty35ConfigurationChangeAuditTrail: """Property 35: Configuration change audit trail. **Validates: Requirements 16.6** """ @settings(max_examples=100) @given(update=_CONFIG_UPDATES) def test_config_update_returns_previous_and_new( self, update: dict, ) -> None: """PUT /api/trading/config returns previous and new values for every changed field.""" client = _get_client() resp = client.put("/api/trading/config", json=update) assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" data = resp.json() assert "previous" in data, "Response must include 'previous'" assert "updated" in data, "Response must include 'updated'" assert "change_source" in data, "Response must include 'change_source'" assert "changed_at" in data, "Response must include 'changed_at'" # Every field sent in the request must appear in both previous and updated for field_name, new_value in update.items(): assert field_name in data["previous"], ( f"Field '{field_name}' missing from previous config" ) assert field_name in data["updated"], ( f"Field '{field_name}' missing from updated config" ) # The updated value must match what was sent assert data["updated"][field_name] == new_value, ( f"Updated value for '{field_name}' should be {new_value}, " f"got {data['updated'][field_name]}" ) @settings(max_examples=100) @given(update=_CONFIG_UPDATES) def test_config_update_change_source_is_api( self, update: dict, ) -> None: """PUT /api/trading/config always records change_source as 'api'.""" client = _get_client() resp = client.put("/api/trading/config", json=update) assert resp.status_code == 200 assert resp.json()["change_source"] == "api" # --------------------------------------------------------------------------- # Property 29: Persistence round-trip for trading engine state # **Validates: Requirements 3.2, 4.7, 5.5, 6.4, 14.3, 15.4, 16.1** # --------------------------------------------------------------------------- class TestProperty29PersistenceRoundTrip: """Property 29: Persistence round-trip for trading engine state. **Validates: Requirements 3.2, 4.7, 5.5, 6.4, 14.3, 15.4, 16.1** """ def test_status_returns_valid_dict_with_expected_keys(self) -> None: """GET /api/trading/status returns a dict with all expected keys.""" client = _get_client() resp = client.get("/api/trading/status") assert resp.status_code == 200 data = resp.json() expected_keys = { "enabled", "paused", "risk_tier", "circuit_breaker_status", "active_pool", "reserve_pool", "portfolio_heat", "open_positions", "last_decision_at", } assert expected_keys.issubset(data.keys()), ( f"Missing keys: {expected_keys - data.keys()}" ) def test_pause_then_status_shows_paused(self) -> None: """POST /api/trading/pause followed by GET /api/trading/status shows paused=true.""" client = _get_client() pause_resp = client.post("/api/trading/pause") assert pause_resp.status_code == 200 assert pause_resp.json()["paused"] is True status_resp = client.get("/api/trading/status") assert status_resp.status_code == 200 assert status_resp.json()["paused"] is True def test_resume_then_status_shows_not_paused(self) -> None: """POST /api/trading/resume followed by GET /api/trading/status shows paused=false.""" client = _get_client() resume_resp = client.post("/api/trading/resume") assert resume_resp.status_code == 200 assert resume_resp.json()["paused"] is False status_resp = client.get("/api/trading/status") assert status_resp.status_code == 200 assert status_resp.json()["paused"] is False @settings(max_examples=100) @given(risk_tier=_RISK_TIERS) def test_config_round_trip_risk_tier(self, risk_tier: str) -> None: """Setting risk_tier via config update is reflected in status.""" client = _get_client() resp = client.put( "/api/trading/config", json={"risk_tier": risk_tier}, ) assert resp.status_code == 200 assert resp.json()["updated"]["risk_tier"] == risk_tier status = client.get("/api/trading/status").json() assert status["risk_tier"] == risk_tier @settings(max_examples=100) @given(enabled=st.booleans()) def test_config_round_trip_enabled(self, enabled: bool) -> None: """Setting enabled via config update is reflected in status.""" client = _get_client() resp = client.put( "/api/trading/config", json={"enabled": enabled}, ) assert resp.status_code == 200 assert resp.json()["updated"]["enabled"] == enabled status = client.get("/api/trading/status").json() assert status["enabled"] == enabled def test_health_returns_ok(self) -> None: """GET /health always returns status ok.""" client = _get_client() resp = client.get("/health") assert resp.status_code == 200 assert resp.json() == {"status": "ok"} def test_ready_returns_boolean(self) -> None: """GET /ready returns a dict with a boolean 'ready' field.""" client = _get_client() resp = client.get("/ready") assert resp.status_code == 200 assert isinstance(resp.json()["ready"], bool) def test_backtest_returns_id(self) -> None: """POST /api/trading/backtest returns an id string.""" client = _get_client() resp = client.post( "/api/trading/backtest", json={ "start_date": "2024-01-01", "end_date": "2024-06-30", "initial_capital": 500.0, "risk_tier": "moderate", }, ) assert resp.status_code == 200 data = resp.json() assert "id" in data assert isinstance(data["id"], str) assert len(data["id"]) > 0 def test_backtest_get_returns_placeholder(self) -> None: """GET /api/trading/backtest/{id} returns a result dict.""" client = _get_client() test_id = "test-123" resp = client.get(f"/api/trading/backtest/{test_id}") assert resp.status_code == 200 data = resp.json() assert data["id"] == test_id def test_decisions_returns_list(self) -> None: """GET /api/trading/decisions returns a list.""" client = _get_client() resp = client.get("/api/trading/decisions") assert resp.status_code == 200 assert isinstance(resp.json(), list) def test_metrics_returns_expected_keys(self) -> None: """GET /api/trading/metrics returns a dict with expected metric keys.""" client = _get_client() resp = client.get("/api/trading/metrics") assert resp.status_code == 200 data = resp.json() expected_keys = { "total_portfolio_value", "active_pool", "reserve_pool", "unrealized_pnl", "realized_pnl", "daily_pnl", "win_rate", "profit_factor", "sharpe_ratio", "max_drawdown", "portfolio_heat", } assert expected_keys.issubset(data.keys()), ( f"Missing keys: {expected_keys - data.keys()}" ) def test_metrics_history_returns_list(self) -> None: """GET /api/trading/metrics/history returns a list.""" client = _get_client() resp = client.get("/api/trading/metrics/history") assert resp.status_code == 200 assert isinstance(resp.json(), list) def test_notification_config_returns_dict(self) -> None: """GET /api/trading/notifications/config returns a dict.""" client = _get_client() resp = client.get("/api/trading/notifications/config") assert resp.status_code == 200 data = resp.json() assert "sms_enabled" in data assert "email_enabled" in data def test_notification_history_returns_list(self) -> None: """GET /api/trading/notifications/history returns a list.""" client = _get_client() resp = client.get("/api/trading/notifications/history") assert resp.status_code == 200 assert isinstance(resp.json(), list)