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:
Celes Renata
2026-04-15 16:12:22 +00:00
parent da86132f0c
commit 4ffde8cc06
58 changed files with 14168 additions and 1 deletions
+302
View File
@@ -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)