feat: autonomous trading engine — full implementation
- Database migration 018 with 13 tables for trading engine state - Trading engine service (services/trading/) with 12 pure computation modules: position sizer, stop-loss manager, reserve pool, circuit breaker, risk tier controller, correlation matrix, tax lots, trading window, gradual entry, notifications, micro-trading, backtester - Core TradingEngine with pre-trade evaluation pipeline and integration wiring - FastAPI HTTP service with 14 endpoints (health, config, decisions, metrics, backtest) - Performance tracker with Sharpe ratio, drawdown, profit factor computation - 194 Python tests (165 property-based + 29 integration) - Frontend: 13 TanStack Query hooks, 7 dashboard panels, tabbed Trading Engine page - Helm chart entry, network policy, nginx proxy, ingress for trading-engine - Shared infrastructure: enums, Redis keys, TradingConfig in AppConfig
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
"""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 a backtest_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 "backtest_id" in data
|
||||
assert isinstance(data["backtest_id"], str)
|
||||
assert len(data["backtest_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["backtest_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)
|
||||
Reference in New Issue
Block a user