f2d8744a4f
- ID mismatch: API generated a throwaway UUID while BacktestReplay generated its own internally. Frontend polled with wrong ID and never found the DB row. Now pre-generate ID in endpoint and pass it to BacktestReplay. - Field name: API returned 'backtest_id' but frontend read 'data.id'. Unified to 'id' everywhere. - No polling: useBacktestResult fired once and never refreshed. Added refetchInterval that polls every 2s while status is running. - Response shape: GET endpoint nested results under 'result' object but frontend expected flat fields. Flattened response to match BacktestResult type. - Added running/failed/completed status indicators in BacktestPanel.
303 lines
11 KiB
Python
303 lines
11 KiB
Python
"""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)
|