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
+512
View File
@@ -0,0 +1,512 @@
"""Core autonomous trading engine — decision loop and pre-trade evaluation.
Feature: autonomous-trading-engine
Coordinates all trading sub-components (PositionSizer, StopLossManager,
CircuitBreaker, ReservePoolController, RiskTierController, CorrelationMatrix)
to evaluate recommendations and produce TradingDecision records.
The ``evaluate_recommendation`` method is deliberately synchronous-compatible
so that it can be tested without real DB/Redis connections. The async
``start`` / ``stop`` methods are thin lifecycle stubs wired up in Task 25.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from services.shared.config import TradingConfig
from services.trading.circuit_breaker import CircuitBreaker
from services.trading.correlation import CorrelationMatrix
from services.trading.micro_trading import MicroTradeConfig, MicroTradingModule
from services.trading.models import (
CircuitBreakerState,
OpenPosition,
PerformanceMetrics,
PortfolioState,
PositionSizeResult,
RiskTierConfig,
StopLevels,
StopTrigger,
TradingDecision,
)
from services.trading.notifications import NotificationRecord, NotificationService
from services.trading.position_sizer import PositionSizer
from services.trading.rebalancer import PortfolioRebalancer
from services.trading.reserve_pool import ReservePoolController
from services.trading.risk_tier_controller import RiskTierController
from services.trading.stop_loss_manager import StopLossManager
from services.trading.trading_window import is_within_trading_window
class TradingEngine:
"""Main autonomous trading engine.
Manages the decision loop, coordinates all sub-components,
and maintains runtime state.
Parameters
----------
pool:
asyncpg connection pool (used by async lifecycle methods).
redis:
Redis client (used for deduplication and pub/sub).
config:
Trading engine configuration.
"""
def __init__(
self,
pool: object,
redis: object,
config: TradingConfig,
) -> None:
self.pool = pool
self.redis = redis
self.config = config
# Sub-components
self.position_sizer = PositionSizer()
self.stop_loss_manager = StopLossManager()
self.circuit_breaker = CircuitBreaker()
self.reserve_pool_controller = ReservePoolController(
siphon_pct=config.reserve_siphon_pct,
high_water_pct=config.reserve_high_water_pct,
)
self.risk_tier_controller = RiskTierController()
self.correlation_matrix = CorrelationMatrix()
self.notification_service = NotificationService()
self.micro_trading_module = MicroTradingModule()
self.rebalancer = PortfolioRebalancer()
# Runtime state
self.running: bool = False
self.portfolio_state: PortfolioState | None = None
self.processed_recommendation_ids: set[str] = set()
# ------------------------------------------------------------------
# Lifecycle (stubs — wired in Task 25)
# ------------------------------------------------------------------
async def start(self) -> None:
"""Load portfolio state and enter the decision loop.
Full implementation is deferred to Task 25. This stub sets the
``running`` flag so readiness probes can report status.
"""
self.running = True
async def stop(self) -> None:
"""Graceful shutdown — cancel pending work and persist state.
Full implementation is deferred to Task 25.
"""
self.running = False
# ------------------------------------------------------------------
# Core evaluation logic (synchronous-compatible for testing)
# ------------------------------------------------------------------
def evaluate_recommendation(
self,
rec: dict,
portfolio_state: PortfolioState,
risk_tier: RiskTierConfig,
circuit_breaker_state: CircuitBreakerState,
correlation_matrix: CorrelationMatrix,
earnings_calendar: dict,
now: datetime | None = None,
) -> TradingDecision:
"""Run all pre-trade checks and produce a TradingDecision.
The checks are applied in order; the first failure short-circuits
with a ``skip`` decision. If all checks pass the PositionSizer
is invoked and its result determines the final decision.
Parameters
----------
rec:
Recommendation dict with at least ``recommendation_id``,
``ticker``, ``confidence``, ``sector``, ``current_price``,
and ``action``.
portfolio_state:
Current portfolio snapshot.
risk_tier:
Active risk tier configuration.
circuit_breaker_state:
Current circuit breaker state.
correlation_matrix:
Correlation matrix instance for diversification checks.
earnings_calendar:
Mapping of ticker → next earnings datetime.
now:
Optional override for the current timestamp (for testing).
"""
now = now or datetime.now(tz=timezone.utc)
rec_id = rec.get("recommendation_id")
ticker = rec.get("ticker", "")
confidence = rec.get("confidence", 0.0)
sector = rec.get("sector", "")
current_price = rec.get("current_price", 0.0)
reasoning: list[str] = []
# --- a. Circuit breaker active? --------------------------------
if self.circuit_breaker.is_active(circuit_breaker_state, now=now):
reasoning.append("Circuit breaker is active")
return self._skip_decision(
rec_id=rec_id,
ticker=ticker,
skip_reason="circuit_breaker_active",
risk_tier=risk_tier,
portfolio_state=portfolio_state,
circuit_breaker_state=circuit_breaker_state,
reasoning=reasoning,
now=now,
)
# --- b. Trading window? ----------------------------------------
if not is_within_trading_window(now):
reasoning.append("Outside trading window")
return self._skip_decision(
rec_id=rec_id,
ticker=ticker,
skip_reason="outside_trading_window",
risk_tier=risk_tier,
portfolio_state=portfolio_state,
circuit_breaker_state=circuit_breaker_state,
reasoning=reasoning,
now=now,
)
# --- c. Confidence gate (early check before sizer) -------------
if confidence < risk_tier.min_confidence:
reasoning.append(
f"Confidence {confidence:.4f} below tier minimum "
f"{risk_tier.min_confidence}"
)
return self._skip_decision(
rec_id=rec_id,
ticker=ticker,
skip_reason="insufficient_confidence",
risk_tier=risk_tier,
portfolio_state=portfolio_state,
circuit_breaker_state=circuit_breaker_state,
reasoning=reasoning,
now=now,
)
# --- d. Deduplication check ------------------------------------
if rec_id and rec_id in self.processed_recommendation_ids:
reasoning.append(f"Recommendation {rec_id} already processed")
return self._skip_decision(
rec_id=rec_id,
ticker=ticker,
skip_reason="duplicate_recommendation",
risk_tier=risk_tier,
portfolio_state=portfolio_state,
circuit_breaker_state=circuit_breaker_state,
reasoning=reasoning,
now=now,
)
# --- e. Multiple declining positions check ---------------------
if self.check_declining_positions(portfolio_state.positions):
reasoning.append(
"Multiple declining positions — halting new entries"
)
return self._skip_decision(
rec_id=rec_id,
ticker=ticker,
skip_reason="multiple_declining_positions",
risk_tier=risk_tier,
portfolio_state=portfolio_state,
circuit_breaker_state=circuit_breaker_state,
reasoning=reasoning,
now=now,
)
# --- f. Max open positions check -------------------------------
max_positions = self.config.max_open_positions if hasattr(self.config, "max_open_positions") else 10
if self.check_max_positions(
portfolio_state.open_position_count, max_positions
):
reasoning.append(
f"At max open positions ({portfolio_state.open_position_count}/"
f"{max_positions})"
)
return self._skip_decision(
rec_id=rec_id,
ticker=ticker,
skip_reason="max_positions_reached",
risk_tier=risk_tier,
portfolio_state=portfolio_state,
circuit_breaker_state=circuit_breaker_state,
reasoning=reasoning,
now=now,
)
# --- g. Position sizing ----------------------------------------
reasoning.append("All pre-trade checks passed — computing position size")
# Build the raw correlation dict expected by PositionSizer
corr_dict: dict[tuple[str, str], float] = correlation_matrix._data
size_result: PositionSizeResult = self.position_sizer.compute(
confidence=confidence,
ticker=ticker,
sector=sector,
current_price=current_price,
active_pool=portfolio_state.active_pool,
risk_tier=risk_tier,
portfolio_state=portfolio_state,
correlation_matrix=corr_dict,
earnings_calendar=earnings_calendar,
absolute_position_cap=self.config.absolute_position_cap,
active_pool_minimum=self.config.active_pool_minimum,
)
reasoning.extend(size_result.adjustments)
if size_result.rejected:
reasoning.append(f"Position sizer rejected: {size_result.rejection_reason}")
return self._skip_decision(
rec_id=rec_id,
ticker=ticker,
skip_reason=f"position_sizer_rejected: {size_result.rejection_reason}",
risk_tier=risk_tier,
portfolio_state=portfolio_state,
circuit_breaker_state=circuit_breaker_state,
reasoning=reasoning,
now=now,
)
# Mark recommendation as processed
if rec_id:
self.processed_recommendation_ids.add(rec_id)
return self._act_decision(
rec_id=rec_id,
ticker=ticker,
size_result=size_result,
risk_tier=risk_tier,
portfolio_state=portfolio_state,
circuit_breaker_state=circuit_breaker_state,
reasoning=reasoning,
now=now,
)
# ------------------------------------------------------------------
# Helper checks
# ------------------------------------------------------------------
def check_declining_positions(
self,
positions: list[OpenPosition],
threshold_pct: float = 0.50,
decline_pct: float = 0.02,
) -> bool:
"""Return True if > threshold_pct of positions have > decline_pct negative unrealized P&L.
A position is considered "declining" when its unrealized P&L as a
fraction of its entry value is worse than ``-decline_pct``.
Parameters
----------
positions:
List of currently open positions.
threshold_pct:
Fraction of positions that must be declining to trigger
(default 0.50 = 50%).
decline_pct:
Minimum loss fraction to count as declining
(default 0.02 = 2%).
"""
if not positions:
return False
declining_count = 0
for pos in positions:
entry_value = pos.entry_price * pos.quantity
if entry_value <= 0:
continue
loss_pct = -pos.unrealized_pnl / entry_value
if loss_pct > decline_pct:
declining_count += 1
return declining_count > threshold_pct * len(positions)
def check_max_positions(
self,
open_count: int,
max_positions: int = 10,
) -> bool:
"""Return True if the portfolio is at the maximum number of open positions."""
return open_count >= max_positions
# ------------------------------------------------------------------
# Integration wiring — thin wrappers for the decision loop
# ------------------------------------------------------------------
def check_stop_loss_crossings(
self,
positions: list[OpenPosition],
prices: dict[str, float],
stop_levels: dict[str, StopLevels],
) -> list[StopTrigger]:
"""Delegate to StopLossManager.check_price_crossings().
Called by the async decision loop at the configured interval
(5 min default, 60s during high-severity events).
"""
return self.stop_loss_manager.check_price_crossings(
positions, prices, stop_levels
)
def handle_position_close(
self,
realized_profit: float,
reserve_balance: float,
) -> tuple[float, float]:
"""Delegate to ReservePoolController.siphon_profit().
Called when a position close event is detected from broker
service fill events.
"""
return self.reserve_pool_controller.siphon_profit(
realized_profit, reserve_balance
)
def evaluate_risk_tier(
self,
current_tier: str,
metrics: PerformanceMetrics,
reserve_pct: float,
) -> str | None:
"""Delegate to RiskTierController.evaluate().
Scheduled to run at daily market close.
"""
return self.risk_tier_controller.evaluate(
current_tier, metrics, reserve_pct
)
def evaluate_rebalancing(
self,
positions: list[OpenPosition],
risk_tier: RiskTierConfig,
active_pool: float,
) -> list:
"""Delegate to PortfolioRebalancer.evaluate().
Scheduled to run weekly at Monday market open.
"""
return self.rebalancer.evaluate(positions, risk_tier, active_pool)
def create_alert(
self,
event_type: str,
details: str,
) -> NotificationRecord:
"""Create a notification record for a critical event.
Delegates formatting and record creation to NotificationService.
The caller is responsible for actual delivery.
"""
message = self.notification_service.format_alert(event_type, details)
return self.notification_service.create_notification(
channel="email",
event_type=event_type,
message=message,
)
def check_micro_trade_constraints(
self,
daily_count: int,
is_within_window: bool,
circuit_breaker_active: bool,
portfolio_heat_pct: float,
max_heat: float,
) -> tuple[bool, str]:
"""Delegate to MicroTradingModule.check_constraints().
Called by the decision loop when micro-trading is enabled.
"""
micro_config = MicroTradeConfig(
enabled=self.config.micro_trading_enabled,
allocation_cap_pct=self.config.micro_trading_allocation_cap_pct,
max_daily=self.config.micro_trading_max_daily,
max_hold_minutes=self.config.micro_trading_max_hold_minutes,
)
return self.micro_trading_module.check_constraints(
config=micro_config,
daily_count=daily_count,
is_within_window=is_within_window,
circuit_breaker_active=circuit_breaker_active,
portfolio_heat_pct=portfolio_heat_pct,
max_heat=max_heat,
)
# ------------------------------------------------------------------
# Decision builders
# ------------------------------------------------------------------
def _skip_decision(
self,
*,
rec_id: str | None,
ticker: str,
skip_reason: str,
risk_tier: RiskTierConfig,
portfolio_state: PortfolioState,
circuit_breaker_state: CircuitBreakerState,
reasoning: list[str],
now: datetime,
) -> TradingDecision:
return TradingDecision(
id=str(uuid.uuid4()),
recommendation_id=rec_id,
decision="skip",
skip_reason=skip_reason,
ticker=ticker,
computed_position_size=None,
computed_share_quantity=None,
risk_tier_at_decision=risk_tier.name,
portfolio_heat_at_decision=portfolio_state.portfolio_heat,
active_pool_at_decision=portfolio_state.active_pool,
reserve_pool_at_decision=portfolio_state.reserve_pool,
circuit_breaker_status="active" if circuit_breaker_state.active else "inactive",
decision_trace={"reasoning": reasoning},
created_at=now,
)
def _act_decision(
self,
*,
rec_id: str | None,
ticker: str,
size_result: PositionSizeResult,
risk_tier: RiskTierConfig,
portfolio_state: PortfolioState,
circuit_breaker_state: CircuitBreakerState,
reasoning: list[str],
now: datetime,
) -> TradingDecision:
return TradingDecision(
id=str(uuid.uuid4()),
recommendation_id=rec_id,
decision="act",
skip_reason=None,
ticker=ticker,
computed_position_size=size_result.dollar_amount,
computed_share_quantity=size_result.share_quantity,
risk_tier_at_decision=risk_tier.name,
portfolio_heat_at_decision=portfolio_state.portfolio_heat,
active_pool_at_decision=portfolio_state.active_pool,
reserve_pool_at_decision=portfolio_state.reserve_pool,
circuit_breaker_status="active" if circuit_breaker_state.active else "inactive",
decision_trace={"reasoning": reasoning},
created_at=now,
)