"""Strategy Executor for end-to-end orchestration. This module provides complete strategy execution including: - Signal generation to order conversion - Order submission and execution - Position and portfolio management - Error handling with retries - Comprehensive logging and monitoring Issue #37: [STRAT-36] Strategy executor - end-to-end orchestration Design Principles: - Full trade lifecycle management - Robust error handling with retries - Comprehensive logging - Event-driven architecture """ from dataclasses import dataclass, field from datetime import datetime, timedelta from decimal import Decimal from enum import Enum from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple import asyncio import logging import time import uuid from tradingagents.strategy.signal_to_order import ( SignalToOrderConverter, TradingSignal, SignalType, ConversionConfig, ConversionResult, ) from tradingagents.execution.broker_base import ( OrderRequest, OrderSide, Order, OrderStatus, ) # ============================================================================ # Logging Setup # ============================================================================ logger = logging.getLogger(__name__) # ============================================================================ # Enums # ============================================================================ class ExecutionStatus(str, Enum): """Status of strategy execution.""" PENDING = "pending" # Not started CONVERTING = "converting" # Converting signals to orders SUBMITTING = "submitting" # Submitting orders EXECUTING = "executing" # Orders executing MONITORING = "monitoring" # Monitoring positions COMPLETED = "completed" # Execution complete FAILED = "failed" # Execution failed CANCELLED = "cancelled" # Execution cancelled class RetryPolicy(str, Enum): """Retry policy for failed operations.""" NONE = "none" # No retries IMMEDIATE = "immediate" # Retry immediately EXPONENTIAL = "exponential" # Exponential backoff FIXED = "fixed" # Fixed delay class ExecutionEvent(str, Enum): """Events during execution.""" STARTED = "started" SIGNAL_RECEIVED = "signal_received" ORDER_CREATED = "order_created" ORDER_SUBMITTED = "order_submitted" ORDER_FILLED = "order_filled" ORDER_REJECTED = "order_rejected" ORDER_CANCELLED = "order_cancelled" POSITION_OPENED = "position_opened" POSITION_CLOSED = "position_closed" STOP_TRIGGERED = "stop_triggered" TARGET_REACHED = "target_reached" ERROR = "error" RETRY = "retry" COMPLETED = "completed" FAILED = "failed" # ============================================================================ # Protocols # ============================================================================ class SignalProvider(Protocol): """Protocol for signal generation.""" def get_signals(self, symbols: List[str]) -> List[TradingSignal]: """Generate trading signals for symbols. Args: symbols: List of symbols to analyze Returns: List of trading signals """ ... class OrderExecutor(Protocol): """Protocol for order execution.""" async def submit_order(self, order: OrderRequest) -> Order: """Submit an order for execution. Args: order: Order request to submit Returns: Submitted order with ID """ ... async def cancel_order(self, order_id: str) -> bool: """Cancel an order. Args: order_id: ID of order to cancel Returns: True if cancelled successfully """ ... async def get_order_status(self, order_id: str) -> Order: """Get current order status. Args: order_id: ID of order to check Returns: Order with current status """ ... class PositionManager(Protocol): """Protocol for position management.""" def get_position(self, symbol: str) -> Optional[Dict[str, Any]]: """Get current position for symbol. Args: symbol: Trading symbol Returns: Position data or None """ ... def update_position( self, symbol: str, quantity: Decimal, avg_price: Decimal, ) -> None: """Update position after fill. Args: symbol: Trading symbol quantity: Filled quantity avg_price: Average fill price """ ... # ============================================================================ # Data Classes # ============================================================================ @dataclass class RetryConfig: """Configuration for retry behavior. Attributes: policy: Retry policy to use max_retries: Maximum retry attempts initial_delay_ms: Initial delay in milliseconds max_delay_ms: Maximum delay in milliseconds backoff_multiplier: Multiplier for exponential backoff """ policy: RetryPolicy = RetryPolicy.EXPONENTIAL max_retries: int = 3 initial_delay_ms: int = 100 max_delay_ms: int = 5000 backoff_multiplier: float = 2.0 @dataclass class MonitoringConfig: """Configuration for execution monitoring. Attributes: log_all_events: Log all execution events log_level: Default logging level track_latency: Track order latency track_fills: Track fill quality alert_on_failure: Alert on execution failure metrics_interval_seconds: Interval for metrics collection """ log_all_events: bool = True log_level: str = "INFO" track_latency: bool = True track_fills: bool = True alert_on_failure: bool = True metrics_interval_seconds: int = 60 @dataclass class ExecutorConfig: """Configuration for strategy executor. Attributes: conversion_config: Signal to order conversion config retry_config: Retry behavior config monitoring_config: Monitoring and logging config max_concurrent_orders: Maximum concurrent orders order_timeout_seconds: Order fill timeout enable_stop_orders: Submit stop loss orders enable_take_profit: Submit take profit orders dry_run: Simulate without actual execution """ conversion_config: ConversionConfig = field(default_factory=ConversionConfig) retry_config: RetryConfig = field(default_factory=RetryConfig) monitoring_config: MonitoringConfig = field(default_factory=MonitoringConfig) max_concurrent_orders: int = 10 order_timeout_seconds: int = 300 enable_stop_orders: bool = True enable_take_profit: bool = True dry_run: bool = False @dataclass class ExecutionEvent_: """An event during strategy execution. Attributes: event_id: Unique event identifier event_type: Type of event timestamp: When event occurred signal_id: Associated signal ID order_id: Associated order ID symbol: Trading symbol message: Event message data: Additional event data """ event_id: str = field(default_factory=lambda: str(uuid.uuid4())) event_type: ExecutionEvent = ExecutionEvent.STARTED timestamp: datetime = field(default_factory=datetime.now) signal_id: Optional[str] = None order_id: Optional[str] = None symbol: str = "" message: str = "" data: Dict[str, Any] = field(default_factory=dict) @dataclass class OrderExecution: """Record of a single order execution. Attributes: execution_id: Unique execution identifier signal: Original signal conversion_result: Signal conversion result order_request: Generated order request submitted_order: Order after submission final_status: Final order status fill_price: Average fill price fill_quantity: Filled quantity submit_time: When order was submitted fill_time: When order was filled latency_ms: Total latency in milliseconds retries: Number of retries used error_message: Error if failed """ execution_id: str = field(default_factory=lambda: str(uuid.uuid4())) signal: Optional[TradingSignal] = None conversion_result: Optional[ConversionResult] = None order_request: Optional[OrderRequest] = None submitted_order: Optional[Order] = None final_status: OrderStatus = OrderStatus.PENDING_NEW fill_price: Optional[Decimal] = None fill_quantity: Optional[Decimal] = None submit_time: Optional[datetime] = None fill_time: Optional[datetime] = None latency_ms: Optional[int] = None retries: int = 0 error_message: str = "" @dataclass class ExecutionResult: """Result of strategy execution. Attributes: result_id: Unique result identifier status: Final execution status start_time: Execution start time end_time: Execution end time total_signals: Number of signals processed orders_submitted: Number of orders submitted orders_filled: Number of orders filled orders_rejected: Number of orders rejected orders_cancelled: Number of orders cancelled total_value: Total value executed executions: Individual order executions events: Execution events log errors: Error messages metrics: Execution metrics """ result_id: str = field(default_factory=lambda: str(uuid.uuid4())) status: ExecutionStatus = ExecutionStatus.PENDING start_time: datetime = field(default_factory=datetime.now) end_time: Optional[datetime] = None total_signals: int = 0 orders_submitted: int = 0 orders_filled: int = 0 orders_rejected: int = 0 orders_cancelled: int = 0 total_value: Decimal = Decimal("0") executions: List[OrderExecution] = field(default_factory=list) events: List[ExecutionEvent_] = field(default_factory=list) errors: List[str] = field(default_factory=list) metrics: Dict[str, Any] = field(default_factory=dict) # ============================================================================ # StrategyExecutor Class # ============================================================================ class StrategyExecutor: """Orchestrates end-to-end strategy execution. Handles the complete trade lifecycle: 1. Receive signals from signal provider 2. Convert signals to orders 3. Submit orders for execution 4. Monitor order status 5. Manage positions 6. Handle errors and retries Attributes: config: Executor configuration signal_converter: Signal to order converter order_executor: Order execution handler position_manager: Position management event_handlers: Event callback handlers """ def __init__( self, config: Optional[ExecutorConfig] = None, order_executor: Optional[OrderExecutor] = None, position_manager: Optional[PositionManager] = None, portfolio_value: Decimal = Decimal("100000"), current_prices: Optional[Dict[str, Decimal]] = None, ): """Initialize strategy executor. Args: config: Executor configuration order_executor: Order execution handler position_manager: Position management handler portfolio_value: Current portfolio value current_prices: Current market prices """ self.config = config or ExecutorConfig() self.order_executor = order_executor self.position_manager = position_manager # Initialize signal converter self.signal_converter = SignalToOrderConverter( config=self.config.conversion_config, portfolio_value=portfolio_value, current_prices=current_prices or {}, ) # Event handlers self.event_handlers: Dict[ExecutionEvent, List[Callable]] = { event: [] for event in ExecutionEvent } # Execution state self._current_result: Optional[ExecutionResult] = None self._pending_orders: Dict[str, OrderExecution] = {} self._is_running = False def register_event_handler( self, event_type: ExecutionEvent, handler: Callable[[ExecutionEvent_], None], ) -> None: """Register a handler for execution events. Args: event_type: Type of event to handle handler: Callback function """ self.event_handlers[event_type].append(handler) def _emit_event( self, event_type: ExecutionEvent, signal_id: Optional[str] = None, order_id: Optional[str] = None, symbol: str = "", message: str = "", data: Optional[Dict[str, Any]] = None, ) -> ExecutionEvent_: """Emit an execution event. Args: event_type: Type of event signal_id: Associated signal ID order_id: Associated order ID symbol: Trading symbol message: Event message data: Additional data Returns: The emitted event """ event = ExecutionEvent_( event_type=event_type, signal_id=signal_id, order_id=order_id, symbol=symbol, message=message, data=data or {}, ) # Log event if self.config.monitoring_config.log_all_events: logger.log( getattr(logging, self.config.monitoring_config.log_level), f"[{event_type.value}] {symbol}: {message}", ) # Record event if self._current_result: self._current_result.events.append(event) # Call handlers for handler in self.event_handlers[event_type]: try: handler(event) except Exception as e: logger.error(f"Event handler error: {e}") return event def execute_signals( self, signals: List[TradingSignal], ) -> ExecutionResult: """Execute a list of trading signals synchronously. Args: signals: List of signals to execute Returns: ExecutionResult with all execution details """ # Initialize result result = ExecutionResult( status=ExecutionStatus.PENDING, start_time=datetime.now(), total_signals=len(signals), ) self._current_result = result self._emit_event( ExecutionEvent.STARTED, message=f"Starting execution of {len(signals)} signals", ) try: result.status = ExecutionStatus.CONVERTING # Convert signals to orders for signal in signals: if signal.signal_type == SignalType.HOLD: continue self._emit_event( ExecutionEvent.SIGNAL_RECEIVED, signal_id=signal.signal_id, symbol=signal.symbol, message=f"Received {signal.signal_type.value} signal", ) execution = self._process_signal(signal) result.executions.append(execution) if execution.order_request: result.orders_submitted += 1 self._emit_event( ExecutionEvent.ORDER_CREATED, signal_id=signal.signal_id, order_id=execution.order_request.client_order_id, symbol=signal.symbol, message="Order created from signal", ) # In dry run mode, mark all as filled if self.config.dry_run: for execution in result.executions: if execution.order_request: execution.final_status = OrderStatus.FILLED execution.fill_price = ( execution.order_request.limit_price or self.signal_converter.current_prices.get( execution.order_request.symbol, Decimal("0") ) ) execution.fill_quantity = execution.order_request.quantity result.orders_filled += 1 result.total_value += ( execution.fill_price * execution.fill_quantity ) # Update final counts for execution in result.executions: if execution.final_status == OrderStatus.REJECTED: result.orders_rejected += 1 elif execution.final_status == OrderStatus.CANCELLED: result.orders_cancelled += 1 result.status = ExecutionStatus.COMPLETED result.end_time = datetime.now() self._emit_event( ExecutionEvent.COMPLETED, message=f"Execution complete: {result.orders_filled} filled, " f"{result.orders_rejected} rejected", ) except Exception as e: result.status = ExecutionStatus.FAILED result.end_time = datetime.now() result.errors.append(str(e)) self._emit_event( ExecutionEvent.FAILED, message=f"Execution failed: {str(e)}", ) # Calculate metrics result.metrics = self._calculate_metrics(result) self._current_result = None return result async def execute_signals_async( self, signals: List[TradingSignal], ) -> ExecutionResult: """Execute signals asynchronously with real order submission. Args: signals: List of signals to execute Returns: ExecutionResult with all execution details """ if self.order_executor is None: raise ValueError("Order executor required for async execution") result = ExecutionResult( status=ExecutionStatus.PENDING, start_time=datetime.now(), total_signals=len(signals), ) self._current_result = result self._is_running = True self._emit_event( ExecutionEvent.STARTED, message=f"Starting async execution of {len(signals)} signals", ) try: result.status = ExecutionStatus.CONVERTING # Convert and submit orders concurrently tasks = [] for signal in signals: if signal.signal_type == SignalType.HOLD: continue self._emit_event( ExecutionEvent.SIGNAL_RECEIVED, signal_id=signal.signal_id, symbol=signal.symbol, message=f"Received {signal.signal_type.value} signal", ) execution = self._process_signal(signal) result.executions.append(execution) if execution.order_request: # Submit order asynchronously task = self._submit_order_with_retry(execution) tasks.append(task) result.status = ExecutionStatus.SUBMITTING # Wait for all orders to be submitted if tasks: submitted = await asyncio.gather(*tasks, return_exceptions=True) for i, sub_result in enumerate(submitted): if isinstance(sub_result, Exception): result.errors.append(str(sub_result)) else: result.orders_submitted += 1 result.status = ExecutionStatus.MONITORING # Monitor orders until filled or timeout await self._monitor_orders(result) # Update final counts for execution in result.executions: if execution.final_status == OrderStatus.FILLED: result.orders_filled += 1 if execution.fill_price and execution.fill_quantity: result.total_value += ( execution.fill_price * execution.fill_quantity ) elif execution.final_status == OrderStatus.REJECTED: result.orders_rejected += 1 elif execution.final_status == OrderStatus.CANCELLED: result.orders_cancelled += 1 result.status = ExecutionStatus.COMPLETED result.end_time = datetime.now() self._emit_event( ExecutionEvent.COMPLETED, message=f"Execution complete: {result.orders_filled} filled", ) except Exception as e: result.status = ExecutionStatus.FAILED result.end_time = datetime.now() result.errors.append(str(e)) self._emit_event( ExecutionEvent.FAILED, message=f"Execution failed: {str(e)}", ) finally: self._is_running = False result.metrics = self._calculate_metrics(result) self._current_result = None return result def _process_signal(self, signal: TradingSignal) -> OrderExecution: """Process a single signal into an order execution. Args: signal: Trading signal to process Returns: OrderExecution record """ execution = OrderExecution(signal=signal) # Convert signal to order conversion = self.signal_converter.convert(signal) execution.conversion_result = conversion if conversion.success and conversion.order_request: execution.order_request = conversion.order_request else: execution.error_message = conversion.error_message self._emit_event( ExecutionEvent.ERROR, signal_id=signal.signal_id, symbol=signal.symbol, message=f"Signal conversion failed: {conversion.error_message}", ) return execution async def _submit_order_with_retry( self, execution: OrderExecution, ) -> bool: """Submit order with retry logic. Args: execution: Order execution record Returns: True if submitted successfully """ retry_config = self.config.retry_config delay_ms = retry_config.initial_delay_ms for attempt in range(retry_config.max_retries + 1): try: execution.submit_time = datetime.now() if self.order_executor: order = await self.order_executor.submit_order( execution.order_request ) execution.submitted_order = order self._emit_event( ExecutionEvent.ORDER_SUBMITTED, signal_id=execution.signal.signal_id if execution.signal else None, order_id=order.broker_order_id, symbol=execution.order_request.symbol, message="Order submitted successfully", ) # Track pending order self._pending_orders[order.broker_order_id] = execution return True except Exception as e: execution.retries = attempt + 1 error_msg = f"Order submission failed (attempt {attempt + 1}): {e}" if attempt < retry_config.max_retries: self._emit_event( ExecutionEvent.RETRY, signal_id=execution.signal.signal_id if execution.signal else None, symbol=execution.order_request.symbol if execution.order_request else "", message=error_msg, ) # Apply delay if retry_config.policy == RetryPolicy.EXPONENTIAL: await asyncio.sleep(delay_ms / 1000) delay_ms = min( delay_ms * retry_config.backoff_multiplier, retry_config.max_delay_ms, ) elif retry_config.policy == RetryPolicy.FIXED: await asyncio.sleep(retry_config.initial_delay_ms / 1000) else: execution.error_message = str(e) self._emit_event( ExecutionEvent.ERROR, signal_id=execution.signal.signal_id if execution.signal else None, symbol=execution.order_request.symbol if execution.order_request else "", message=f"Order submission failed after {attempt + 1} attempts: {e}", ) return False return False async def _monitor_orders(self, result: ExecutionResult) -> None: """Monitor pending orders until completion. Args: result: Execution result to update """ timeout = self.config.order_timeout_seconds start_time = time.time() while self._pending_orders and self._is_running: if time.time() - start_time > timeout: # Timeout - cancel remaining orders for order_id, execution in list(self._pending_orders.items()): execution.final_status = OrderStatus.CANCELLED execution.error_message = "Order timeout" if self.order_executor: try: await self.order_executor.cancel_order(order_id) except Exception: pass self._emit_event( ExecutionEvent.ORDER_CANCELLED, order_id=order_id, message="Order cancelled due to timeout", ) self._pending_orders.clear() break # Check each pending order for order_id, execution in list(self._pending_orders.items()): if self.order_executor: try: order = await self.order_executor.get_order_status(order_id) execution.submitted_order = order if order.status == OrderStatus.FILLED: execution.final_status = OrderStatus.FILLED execution.fill_price = order.filled_avg_price execution.fill_quantity = order.filled_quantity execution.fill_time = datetime.now() if execution.submit_time: execution.latency_ms = int( (execution.fill_time - execution.submit_time).total_seconds() * 1000 ) self._emit_event( ExecutionEvent.ORDER_FILLED, order_id=order_id, symbol=order.symbol, message=f"Order filled at {order.filled_avg_price}", ) del self._pending_orders[order_id] # Update position if self.position_manager: self.position_manager.update_position( order.symbol, order.filled_quantity, order.filled_avg_price, ) elif order.status == OrderStatus.REJECTED: execution.final_status = OrderStatus.REJECTED self._emit_event( ExecutionEvent.ORDER_REJECTED, order_id=order_id, symbol=order.symbol, message="Order rejected", ) del self._pending_orders[order_id] elif order.status == OrderStatus.CANCELLED: execution.final_status = OrderStatus.CANCELLED self._emit_event( ExecutionEvent.ORDER_CANCELLED, order_id=order_id, symbol=order.symbol, message="Order cancelled", ) del self._pending_orders[order_id] except Exception as e: logger.error(f"Error checking order {order_id}: {e}") # Small delay between checks await asyncio.sleep(0.1) def _calculate_metrics(self, result: ExecutionResult) -> Dict[str, Any]: """Calculate execution metrics. Args: result: Execution result Returns: Dict of metrics """ metrics = { "total_signals": result.total_signals, "orders_submitted": result.orders_submitted, "orders_filled": result.orders_filled, "fill_rate": ( result.orders_filled / result.orders_submitted if result.orders_submitted > 0 else 0 ), "orders_rejected": result.orders_rejected, "orders_cancelled": result.orders_cancelled, "total_value": str(result.total_value), "total_errors": len(result.errors), } # Calculate latency metrics latencies = [ e.latency_ms for e in result.executions if e.latency_ms is not None ] if latencies: metrics["avg_latency_ms"] = sum(latencies) / len(latencies) metrics["min_latency_ms"] = min(latencies) metrics["max_latency_ms"] = max(latencies) # Calculate retry metrics total_retries = sum(e.retries for e in result.executions) metrics["total_retries"] = total_retries # Calculate duration if result.end_time: duration = (result.end_time - result.start_time).total_seconds() metrics["duration_seconds"] = duration return metrics def cancel_execution(self) -> None: """Cancel ongoing execution.""" self._is_running = False if self._current_result: self._current_result.status = ExecutionStatus.CANCELLED self._emit_event( ExecutionEvent.FAILED, message="Execution cancelled by user", ) def update_prices(self, prices: Dict[str, Decimal]) -> None: """Update current market prices. Args: prices: Dict of symbol to price """ for symbol, price in prices.items(): self.signal_converter.update_price(symbol, price) def update_portfolio_value(self, value: Decimal) -> None: """Update portfolio value. Args: value: New portfolio value """ self.signal_converter.update_portfolio_value(value) def get_execution_summary(self, result: ExecutionResult) -> str: """Generate a summary report of execution. Args: result: Execution result Returns: Formatted summary string """ lines = [ "# Execution Summary", f"**Status**: {result.status.value}", f"**Start**: {result.start_time}", f"**End**: {result.end_time}", "", "## Order Statistics", f"- Signals processed: {result.total_signals}", f"- Orders submitted: {result.orders_submitted}", f"- Orders filled: {result.orders_filled}", f"- Orders rejected: {result.orders_rejected}", f"- Orders cancelled: {result.orders_cancelled}", f"- Total value: ${result.total_value:,.2f}", "", ] if result.metrics: lines.extend([ "## Metrics", f"- Fill rate: {result.metrics.get('fill_rate', 0):.1%}", f"- Avg latency: {result.metrics.get('avg_latency_ms', 0):.0f}ms", f"- Total retries: {result.metrics.get('total_retries', 0)}", f"- Duration: {result.metrics.get('duration_seconds', 0):.1f}s", "", ]) if result.errors: lines.extend([ "## Errors", ]) for error in result.errors[:5]: lines.append(f"- {error}") return "\n".join(lines)