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
+728
View File
@@ -0,0 +1,728 @@
"""Integration tests for the TradingEngine wiring and end-to-end decision flow.
Tests verify that the engine correctly delegates to sub-components and
that the full recommendation → evaluation → sizing → decision pipeline
works end-to-end with concrete values. No DB/Redis needed — all
components are pure computation.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from services.shared.config import TradingConfig
from services.trading.circuit_breaker import CircuitBreaker
from services.trading.correlation import CorrelationMatrix
from services.trading.engine import TradingEngine
from services.trading.models import (
CircuitBreakerState,
OpenPosition,
PerformanceMetrics,
PortfolioState,
RiskTierConfig,
StopLevels,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engine(**overrides) -> TradingEngine:
"""Create a TradingEngine with sensible test defaults."""
config_kwargs = {
"enabled": True,
"micro_trading_enabled": False,
}
config_kwargs.update(overrides)
config = TradingConfig(**config_kwargs)
return TradingEngine(pool=None, redis=None, config=config)
def _moderate_tier() -> RiskTierConfig:
return RiskTierConfig(
name="moderate",
min_confidence=0.55,
max_position_pct=0.10,
stop_loss_atr_multiplier=2.0,
reward_risk_ratio=1.5,
max_sector_pct=0.30,
max_portfolio_heat=0.20,
)
def _portfolio_state(**overrides) -> PortfolioState:
defaults = {
"positions": [],
"total_value": 500.0,
"cash": 400.0,
"active_pool": 400.0,
"reserve_pool": 100.0,
"sector_exposure": {},
"portfolio_heat": 0.0,
"open_position_count": 0,
}
defaults.update(overrides)
return PortfolioState(**defaults)
def _inactive_cb() -> CircuitBreakerState:
return CircuitBreakerState(active=False)
def _within_trading_window() -> datetime:
"""Return a datetime that is within the trading window (Wed 11:00 AM ET)."""
from zoneinfo import ZoneInfo
return datetime(2025, 1, 15, 11, 0, 0, tzinfo=ZoneInfo("America/New_York"))
# ---------------------------------------------------------------------------
# Test 1: Full cycle — recommendation → evaluation → position sizing → act
# ---------------------------------------------------------------------------
class TestFullDecisionCycle:
"""Verify the complete recommendation-to-decision pipeline."""
def test_high_confidence_recommendation_produces_act_decision(self):
engine = _make_engine(absolute_position_cap=100.0)
tier = _moderate_tier()
portfolio = _portfolio_state(active_pool=1000.0, total_value=1200.0)
cb_state = _inactive_cb()
corr = CorrelationMatrix()
now = _within_trading_window()
rec = {
"recommendation_id": "rec-001",
"ticker": "AAPL",
"confidence": 0.80,
"sector": "Technology",
"current_price": 25.0, # cheap enough to buy at least 1 share
"action": "buy",
}
decision = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=tier,
circuit_breaker_state=cb_state,
correlation_matrix=corr,
earnings_calendar={},
now=now,
)
assert decision.decision == "act"
assert decision.ticker == "AAPL"
assert decision.computed_position_size is not None
assert decision.computed_position_size > 0
assert decision.computed_share_quantity is not None
assert decision.computed_share_quantity > 0
assert decision.risk_tier_at_decision == "moderate"
assert decision.circuit_breaker_status == "inactive"
def test_low_confidence_recommendation_produces_skip_decision(self):
engine = _make_engine()
tier = _moderate_tier()
portfolio = _portfolio_state()
cb_state = _inactive_cb()
corr = CorrelationMatrix()
now = _within_trading_window()
rec = {
"recommendation_id": "rec-002",
"ticker": "MSFT",
"confidence": 0.30, # below moderate min_confidence of 0.55
"sector": "Technology",
"current_price": 400.0,
"action": "buy",
}
decision = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=tier,
circuit_breaker_state=cb_state,
correlation_matrix=corr,
earnings_calendar={},
now=now,
)
assert decision.decision == "skip"
assert "insufficient_confidence" in decision.skip_reason
def test_duplicate_recommendation_is_skipped(self):
engine = _make_engine(absolute_position_cap=100.0)
tier = _moderate_tier()
portfolio = _portfolio_state(active_pool=1000.0, total_value=1200.0)
cb_state = _inactive_cb()
corr = CorrelationMatrix()
now = _within_trading_window()
rec = {
"recommendation_id": "rec-dup",
"ticker": "GOOG",
"confidence": 0.80,
"sector": "Technology",
"current_price": 25.0,
"action": "buy",
}
# First evaluation should act
d1 = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=tier,
circuit_breaker_state=cb_state,
correlation_matrix=corr,
earnings_calendar={},
now=now,
)
assert d1.decision == "act"
# Second evaluation of same rec should skip as duplicate
d2 = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=tier,
circuit_breaker_state=cb_state,
correlation_matrix=corr,
earnings_calendar={},
now=now,
)
assert d2.decision == "skip"
assert "duplicate" in d2.skip_reason
# ---------------------------------------------------------------------------
# Test 2: Stop-loss crossing → StopTrigger list
# ---------------------------------------------------------------------------
class TestStopLossCrossings:
"""Verify stop-loss crossing detection via engine wiring."""
def test_stop_loss_triggered(self):
engine = _make_engine()
positions = [
OpenPosition(
ticker="AAPL",
quantity=10,
entry_price=150.0,
current_price=140.0,
unrealized_pnl=-100.0,
market_value=1400.0,
sector="Technology",
stop_loss_price=145.0,
take_profit_price=165.0,
signal_confidence=0.75,
),
]
prices = {"AAPL": 144.0} # below stop_loss_price of 145.0
stop_levels = {
"AAPL": StopLevels(
stop_loss_price=145.0,
take_profit_price=165.0,
trailing_stop_active=False,
atr_value=3.0,
atr_multiplier=2.0,
reward_risk_ratio=1.5,
),
}
triggers = engine.check_stop_loss_crossings(positions, prices, stop_levels)
assert len(triggers) == 1
assert triggers[0].ticker == "AAPL"
assert triggers[0].trigger_type == "stop_loss"
assert triggers[0].current_price == 144.0
def test_take_profit_triggered(self):
engine = _make_engine()
positions = [
OpenPosition(
ticker="MSFT",
quantity=5,
entry_price=400.0,
current_price=420.0,
unrealized_pnl=100.0,
market_value=2100.0,
sector="Technology",
stop_loss_price=390.0,
take_profit_price=415.0,
signal_confidence=0.80,
),
]
prices = {"MSFT": 416.0} # above take_profit_price of 415.0
stop_levels = {
"MSFT": StopLevels(
stop_loss_price=390.0,
take_profit_price=415.0,
trailing_stop_active=False,
atr_value=5.0,
atr_multiplier=2.0,
reward_risk_ratio=1.5,
),
}
triggers = engine.check_stop_loss_crossings(positions, prices, stop_levels)
assert len(triggers) == 1
assert triggers[0].ticker == "MSFT"
assert triggers[0].trigger_type == "take_profit"
def test_no_crossing_returns_empty(self):
engine = _make_engine()
positions = [
OpenPosition(
ticker="GOOG",
quantity=3,
entry_price=170.0,
current_price=172.0,
unrealized_pnl=6.0,
market_value=516.0,
sector="Technology",
stop_loss_price=165.0,
take_profit_price=180.0,
signal_confidence=0.70,
),
]
prices = {"GOOG": 172.0} # between stop and take-profit
stop_levels = {
"GOOG": StopLevels(
stop_loss_price=165.0,
take_profit_price=180.0,
trailing_stop_active=False,
atr_value=3.0,
atr_multiplier=2.0,
reward_risk_ratio=1.5,
),
}
triggers = engine.check_stop_loss_crossings(positions, prices, stop_levels)
assert len(triggers) == 0
# ---------------------------------------------------------------------------
# Test 3: Reserve pool siphoning on profitable close
# ---------------------------------------------------------------------------
class TestReservePoolSiphoning:
"""Verify reserve pool siphoning via engine wiring."""
def test_profitable_close_siphons_20_percent(self):
engine = _make_engine(reserve_siphon_pct=0.20)
transfer, new_balance = engine.handle_position_close(
realized_profit=100.0,
reserve_balance=50.0,
)
assert transfer == pytest.approx(20.0) # 20% of $100
assert new_balance == pytest.approx(70.0) # $50 + $20
def test_loss_does_not_siphon(self):
engine = _make_engine(reserve_siphon_pct=0.20)
transfer, new_balance = engine.handle_position_close(
realized_profit=-30.0,
reserve_balance=50.0,
)
assert transfer == 0.0
assert new_balance == 50.0
def test_zero_profit_does_not_siphon(self):
engine = _make_engine(reserve_siphon_pct=0.20)
transfer, new_balance = engine.handle_position_close(
realized_profit=0.0,
reserve_balance=50.0,
)
assert transfer == 0.0
assert new_balance == 50.0
# ---------------------------------------------------------------------------
# Test 4: Circuit breaker trigger → halt → cooldown → resume
# ---------------------------------------------------------------------------
class TestCircuitBreakerFlow:
"""Verify circuit breaker integration with the decision loop."""
def test_active_circuit_breaker_skips_recommendation(self):
engine = _make_engine()
tier = _moderate_tier()
portfolio = _portfolio_state()
now = _within_trading_window()
# Circuit breaker active with future cooldown
cb_state = CircuitBreakerState(
active=True,
trigger_type="daily_loss",
triggered_at=now - timedelta(minutes=30),
cooldown_expires=now + timedelta(hours=2),
)
rec = {
"recommendation_id": "rec-cb-1",
"ticker": "AAPL",
"confidence": 0.90,
"sector": "Technology",
"current_price": 150.0,
"action": "buy",
}
decision = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=tier,
circuit_breaker_state=cb_state,
correlation_matrix=CorrelationMatrix(),
earnings_calendar={},
now=now,
)
assert decision.decision == "skip"
assert "circuit_breaker_active" in decision.skip_reason
assert decision.circuit_breaker_status == "active"
def test_expired_circuit_breaker_allows_trading(self):
engine = _make_engine(absolute_position_cap=100.0)
tier = _moderate_tier()
portfolio = _portfolio_state(active_pool=1000.0, total_value=1200.0)
now = _within_trading_window()
# Circuit breaker was active but cooldown has expired
cb_state = CircuitBreakerState(
active=True,
trigger_type="daily_loss",
triggered_at=now - timedelta(hours=3),
cooldown_expires=now - timedelta(hours=1), # expired
)
rec = {
"recommendation_id": "rec-cb-2",
"ticker": "AAPL",
"confidence": 0.80,
"sector": "Technology",
"current_price": 25.0,
"action": "buy",
}
decision = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=tier,
circuit_breaker_state=cb_state,
correlation_matrix=CorrelationMatrix(),
earnings_calendar={},
now=now,
)
# Should proceed since cooldown expired
assert decision.decision == "act"
def test_circuit_breaker_daily_loss_detection(self):
"""Verify the CircuitBreaker component detects daily loss threshold."""
cb = CircuitBreaker(daily_loss_pct=0.05)
# 6% loss on $500 portfolio → should trigger
assert cb.check_daily_loss(daily_pnl=-30.0, portfolio_value=500.0) is True
# 3% loss → should not trigger
assert cb.check_daily_loss(daily_pnl=-15.0, portfolio_value=500.0) is False
def test_circuit_breaker_cooldown_computation(self):
"""Verify cooldown expiry is computed correctly."""
cb = CircuitBreaker(volatility_pause_hours=2, ticker_cooldown_hours=48)
now = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
vol_expiry = cb.compute_cooldown_expiry("volatility", now)
assert vol_expiry == now + timedelta(hours=2)
pos_expiry = cb.compute_cooldown_expiry("single_position", now)
assert pos_expiry == now + timedelta(hours=48)
# ---------------------------------------------------------------------------
# Test 5: Engine startup state
# ---------------------------------------------------------------------------
class TestEngineStartup:
"""Verify engine startup state and lifecycle."""
def test_engine_starts_not_running(self):
engine = _make_engine()
assert engine.running is False
@pytest.mark.asyncio
async def test_start_sets_running_flag(self):
engine = _make_engine()
await engine.start()
assert engine.running is True
@pytest.mark.asyncio
async def test_stop_clears_running_flag(self):
engine = _make_engine()
await engine.start()
assert engine.running is True
await engine.stop()
assert engine.running is False
def test_engine_initializes_all_sub_components(self):
engine = _make_engine()
assert engine.position_sizer is not None
assert engine.stop_loss_manager is not None
assert engine.circuit_breaker is not None
assert engine.reserve_pool_controller is not None
assert engine.risk_tier_controller is not None
assert engine.correlation_matrix is not None
assert engine.notification_service is not None
assert engine.micro_trading_module is not None
assert engine.rebalancer is not None
def test_engine_starts_with_empty_processed_ids(self):
engine = _make_engine()
assert len(engine.processed_recommendation_ids) == 0
# ---------------------------------------------------------------------------
# Test 6: Risk tier evaluation wiring
# ---------------------------------------------------------------------------
class TestRiskTierEvaluation:
"""Verify risk tier evaluation via engine wiring."""
def test_downgrade_on_poor_performance(self):
engine = _make_engine()
metrics = PerformanceMetrics(
total_portfolio_value=500.0,
active_pool=400.0,
reserve_pool=100.0,
unrealized_pnl=-20.0,
realized_pnl=-50.0,
daily_pnl=-10.0,
win_count=3,
loss_count=7,
win_rate=0.30, # below 40% → downgrade
avg_win=10.0,
avg_loss=-8.0,
profit_factor=0.5,
sharpe_ratio=-0.5,
max_drawdown=0.20,
current_drawdown_pct=0.18, # above 15% → also triggers downgrade
portfolio_heat=0.10,
)
new_tier = engine.evaluate_risk_tier("moderate", metrics, reserve_pct=0.20)
assert new_tier == "conservative"
def test_upgrade_on_strong_performance(self):
engine = _make_engine()
metrics = PerformanceMetrics(
total_portfolio_value=600.0,
active_pool=400.0,
reserve_pool=200.0,
unrealized_pnl=30.0,
realized_pnl=100.0,
daily_pnl=5.0,
win_count=7,
loss_count=3,
win_rate=0.70, # above 55%
avg_win=15.0,
avg_loss=-5.0,
profit_factor=2.1,
sharpe_ratio=1.5,
max_drawdown=0.05,
current_drawdown_pct=0.02, # below 5%
portfolio_heat=0.08,
)
# reserve_pct > 0.20 and win_rate > 0.55 and drawdown < 0.05
new_tier = engine.evaluate_risk_tier("moderate", metrics, reserve_pct=0.33)
assert new_tier == "aggressive"
def test_no_change_when_metrics_are_neutral(self):
engine = _make_engine()
metrics = PerformanceMetrics(
total_portfolio_value=500.0,
active_pool=400.0,
reserve_pool=100.0,
unrealized_pnl=5.0,
realized_pnl=20.0,
daily_pnl=2.0,
win_count=5,
loss_count=5,
win_rate=0.50, # between 40% and 55%
avg_win=10.0,
avg_loss=-8.0,
profit_factor=1.2,
sharpe_ratio=0.5,
max_drawdown=0.08,
current_drawdown_pct=0.06,
portfolio_heat=0.10,
)
new_tier = engine.evaluate_risk_tier("moderate", metrics, reserve_pct=0.20)
assert new_tier is None
# ---------------------------------------------------------------------------
# Test 7: Rebalancing wiring
# ---------------------------------------------------------------------------
class TestRebalancingWiring:
"""Verify portfolio rebalancing via engine wiring."""
def test_over_concentrated_position_generates_sell_order(self):
engine = _make_engine()
tier = _moderate_tier() # max_position_pct = 0.10
# market_value $200 exceeds 10% of $400 = $40 by $160
# sell_qty = int(160 / 20) = 8 shares
positions = [
OpenPosition(
ticker="AAPL",
quantity=10,
entry_price=18.0,
current_price=20.0,
unrealized_pnl=20.0,
market_value=200.0,
sector="Technology",
stop_loss_price=16.0,
take_profit_price=25.0,
signal_confidence=0.80,
),
]
orders = engine.evaluate_rebalancing(positions, tier, active_pool=400.0)
assert len(orders) >= 1
assert orders[0].ticker == "AAPL"
assert orders[0].action == "sell"
def test_balanced_portfolio_generates_no_orders(self):
engine = _make_engine()
tier = _moderate_tier()
positions = [
OpenPosition(
ticker="AAPL",
quantity=1,
entry_price=30.0,
current_price=30.0,
unrealized_pnl=0.0,
market_value=30.0, # $30 < 10% of $400 = $40
sector="Technology",
stop_loss_price=28.0,
take_profit_price=35.0,
signal_confidence=0.80,
),
]
orders = engine.evaluate_rebalancing(positions, tier, active_pool=400.0)
assert len(orders) == 0
# ---------------------------------------------------------------------------
# Test 8: Notification wiring
# ---------------------------------------------------------------------------
class TestNotificationWiring:
"""Verify notification creation via engine wiring."""
def test_create_alert_returns_notification_record(self):
engine = _make_engine()
record = engine.create_alert(
event_type="circuit_breaker_triggered",
details="Daily loss exceeded 5% threshold",
)
assert record.event_type == "circuit_breaker_triggered"
assert record.channel == "email"
assert "Circuit Breaker Triggered" in record.message
assert "Daily loss exceeded 5% threshold" in record.message
assert record.delivery_status == "pending"
def test_create_alert_for_risk_tier_change(self):
engine = _make_engine()
record = engine.create_alert(
event_type="risk_tier_changed",
details="moderate → conservative due to drawdown",
)
assert record.event_type == "risk_tier_changed"
assert "Risk Tier Changed" in record.message
# ---------------------------------------------------------------------------
# Test 9: Micro-trading constraint wiring
# ---------------------------------------------------------------------------
class TestMicroTradingConstraints:
"""Verify micro-trading constraint checking via engine wiring."""
def test_disabled_micro_trading_rejects(self):
engine = _make_engine(micro_trading_enabled=False)
allowed, reason = engine.check_micro_trade_constraints(
daily_count=0,
is_within_window=True,
circuit_breaker_active=False,
portfolio_heat_pct=0.05,
max_heat=0.20,
)
assert allowed is False
assert reason == "micro_trading_disabled"
def test_enabled_micro_trading_allows(self):
engine = _make_engine(micro_trading_enabled=True)
allowed, reason = engine.check_micro_trade_constraints(
daily_count=0,
is_within_window=True,
circuit_breaker_active=False,
portfolio_heat_pct=0.05,
max_heat=0.20,
)
assert allowed is True
assert reason == "ok"
def test_circuit_breaker_blocks_micro_trade(self):
engine = _make_engine(micro_trading_enabled=True)
allowed, reason = engine.check_micro_trade_constraints(
daily_count=0,
is_within_window=True,
circuit_breaker_active=True,
portfolio_heat_pct=0.05,
max_heat=0.20,
)
assert allowed is False
assert reason == "circuit_breaker_active"
def test_daily_limit_blocks_micro_trade(self):
engine = _make_engine(
micro_trading_enabled=True,
micro_trading_max_daily=10,
)
allowed, reason = engine.check_micro_trade_constraints(
daily_count=10,
is_within_window=True,
circuit_breaker_active=False,
portfolio_heat_pct=0.05,
max_heat=0.20,
)
assert allowed is False
assert reason == "daily_limit_reached"