TradingAgents/tradingagents/execution/order_manager.py

651 lines
20 KiB
Python

"""Order Manager for order lifecycle management.
Issue #27: [EXEC-26] Order types and manager - market, limit, stop, trailing
This module provides order lifecycle management including validation,
state transitions, and event notifications.
Features:
- Order validation before submission
- Order state machine with valid transitions
- Order tracking and retrieval
- Event callbacks for order state changes
- Support for all order types: market, limit, stop, stop_limit, trailing_stop
Example:
>>> from tradingagents.execution import OrderManager, OrderRequest, OrderSide
>>>
>>> manager = OrderManager()
>>> request = OrderRequest.market("AAPL", OrderSide.BUY, Decimal("100"))
>>> order = await manager.submit_order(request, broker)
>>> print(f"Order {order.broker_order_id} status: {order.status}")
"""
from __future__ import annotations
import asyncio
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from decimal import Decimal
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Set, Awaitable
from .broker_base import (
BrokerBase,
Order,
OrderError,
OrderRequest,
OrderSide,
OrderStatus,
OrderType,
TimeInForce,
InvalidOrderError,
)
class OrderEvent(Enum):
"""Order lifecycle events."""
CREATED = "created"
SUBMITTED = "submitted"
ACCEPTED = "accepted"
REJECTED = "rejected"
PARTIALLY_FILLED = "partially_filled"
FILLED = "filled"
PENDING_CANCEL = "pending_cancel"
CANCELLED = "cancelled"
REPLACED = "replaced"
EXPIRED = "expired"
ERROR = "error"
# Valid state transitions for order state machine
VALID_TRANSITIONS: Dict[OrderStatus, Set[OrderStatus]] = {
OrderStatus.PENDING_NEW: {
OrderStatus.NEW,
OrderStatus.REJECTED,
OrderStatus.CANCELLED,
},
OrderStatus.NEW: {
OrderStatus.PARTIALLY_FILLED,
OrderStatus.FILLED,
OrderStatus.PENDING_CANCEL,
OrderStatus.CANCELLED,
OrderStatus.EXPIRED,
OrderStatus.REPLACED,
},
OrderStatus.PARTIALLY_FILLED: {
OrderStatus.PARTIALLY_FILLED,
OrderStatus.FILLED,
OrderStatus.PENDING_CANCEL,
OrderStatus.CANCELLED,
},
OrderStatus.FILLED: set(), # Terminal state
OrderStatus.PENDING_CANCEL: {
OrderStatus.CANCELLED,
OrderStatus.FILLED, # Can fill while cancel is pending
OrderStatus.PARTIALLY_FILLED,
},
OrderStatus.CANCELLED: set(), # Terminal state
OrderStatus.REJECTED: set(), # Terminal state
OrderStatus.EXPIRED: set(), # Terminal state
OrderStatus.REPLACED: set(), # Terminal state
}
# Terminal states (order cannot change after reaching these)
TERMINAL_STATES: Set[OrderStatus] = {
OrderStatus.FILLED,
OrderStatus.CANCELLED,
OrderStatus.REJECTED,
OrderStatus.EXPIRED,
OrderStatus.REPLACED,
}
# Open states (order can still be filled or cancelled)
OPEN_STATES: Set[OrderStatus] = {
OrderStatus.PENDING_NEW,
OrderStatus.NEW,
OrderStatus.PARTIALLY_FILLED,
OrderStatus.PENDING_CANCEL,
}
@dataclass
class OrderValidationResult:
"""Result of order validation.
Attributes:
valid: Whether the order is valid
errors: List of validation error messages
warnings: List of validation warning messages
"""
valid: bool = True
errors: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
@dataclass
class OrderStateChange:
"""Record of an order state change.
Attributes:
order_id: Order identifier
from_status: Previous status
to_status: New status
event: Event that triggered the change
timestamp: When the change occurred
metadata: Additional change details
"""
order_id: str
from_status: Optional[OrderStatus]
to_status: OrderStatus
event: OrderEvent
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
metadata: Dict[str, Any] = field(default_factory=dict)
# Callback type for order events
OrderEventCallback = Callable[[Order, OrderEvent, Dict[str, Any]], Awaitable[None]]
class OrderManager:
"""Manages order lifecycle and state transitions.
The OrderManager provides:
- Order validation before submission
- Order state machine with valid transitions
- Order tracking and retrieval
- Event callbacks for order state changes
- Order history and audit trail
Example:
>>> manager = OrderManager()
>>>
>>> # Register callbacks
>>> async def on_fill(order, event, metadata):
... print(f"Order {order.broker_order_id} filled!")
>>> manager.register_callback(OrderEvent.FILLED, on_fill)
>>>
>>> # Submit order
>>> order = await manager.submit_order(request, broker)
"""
def __init__(
self,
max_orders: int = 10000,
validate_before_submit: bool = True,
) -> None:
"""Initialize order manager.
Args:
max_orders: Maximum orders to track (oldest removed when exceeded)
validate_before_submit: Whether to validate orders before submission
"""
self._orders: Dict[str, Order] = {}
self._order_history: Dict[str, List[OrderStateChange]] = {}
self._callbacks: Dict[OrderEvent, List[OrderEventCallback]] = {
event: [] for event in OrderEvent
}
self._max_orders = max_orders
self._validate_before_submit = validate_before_submit
self._lock = asyncio.Lock()
def register_callback(
self,
event: OrderEvent,
callback: OrderEventCallback,
) -> None:
"""Register a callback for an order event.
Args:
event: Event to listen for
callback: Async callback function(order, event, metadata)
"""
self._callbacks[event].append(callback)
def unregister_callback(
self,
event: OrderEvent,
callback: OrderEventCallback,
) -> None:
"""Unregister a callback.
Args:
event: Event type
callback: Callback to remove
"""
if callback in self._callbacks[event]:
self._callbacks[event].remove(callback)
async def _fire_event(
self,
order: Order,
event: OrderEvent,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Fire callbacks for an event.
Args:
order: Order that triggered event
event: Event type
metadata: Additional event data
"""
metadata = metadata or {}
for callback in self._callbacks[event]:
try:
await callback(order, event, metadata)
except Exception:
# Don't let callback errors break order flow
pass
def validate_order(self, request: OrderRequest) -> OrderValidationResult:
"""Validate an order request.
Args:
request: Order request to validate
Returns:
Validation result with errors/warnings
"""
result = OrderValidationResult()
# Validate quantity
if request.quantity <= 0:
result.valid = False
result.errors.append("Quantity must be positive")
# Validate limit price for limit orders
if request.order_type in (OrderType.LIMIT, OrderType.STOP_LIMIT):
if request.limit_price is None:
result.valid = False
result.errors.append(f"{request.order_type.value} order requires limit_price")
elif request.limit_price <= 0:
result.valid = False
result.errors.append("Limit price must be positive")
# Validate stop price for stop orders
if request.order_type in (OrderType.STOP, OrderType.STOP_LIMIT):
if request.stop_price is None:
result.valid = False
result.errors.append(f"{request.order_type.value} order requires stop_price")
elif request.stop_price <= 0:
result.valid = False
result.errors.append("Stop price must be positive")
# Validate trailing stop parameters
if request.order_type == OrderType.TRAILING_STOP:
if request.trail_amount is None and request.trail_percent is None:
result.valid = False
result.errors.append("Trailing stop requires trail_amount or trail_percent")
if request.trail_amount is not None and request.trail_amount <= 0:
result.valid = False
result.errors.append("Trail amount must be positive")
if request.trail_percent is not None:
if request.trail_percent <= 0:
result.valid = False
result.errors.append("Trail percent must be positive")
elif request.trail_percent > Decimal("50"):
result.warnings.append("Trail percent > 50% may execute far from market")
# Validate symbol
if not request.symbol or not request.symbol.strip():
result.valid = False
result.errors.append("Symbol is required")
# Warn about FOK/IOC with limit orders far from market
if request.time_in_force in (TimeInForce.FOK, TimeInForce.IOC):
if request.order_type == OrderType.MARKET:
result.warnings.append(
f"{request.time_in_force.value} with market order may not execute"
)
return result
def is_valid_transition(
self,
from_status: OrderStatus,
to_status: OrderStatus,
) -> bool:
"""Check if a state transition is valid.
Args:
from_status: Current status
to_status: Target status
Returns:
True if transition is valid
"""
return to_status in VALID_TRANSITIONS.get(from_status, set())
def is_terminal(self, status: OrderStatus) -> bool:
"""Check if a status is terminal.
Args:
status: Status to check
Returns:
True if status is terminal
"""
return status in TERMINAL_STATES
def is_open(self, status: OrderStatus) -> bool:
"""Check if a status means order is open.
Args:
status: Status to check
Returns:
True if order is open
"""
return status in OPEN_STATES
async def submit_order(
self,
request: OrderRequest,
broker: BrokerBase,
) -> Order:
"""Submit an order through a broker.
Args:
request: Order request
broker: Broker to submit through
Returns:
Submitted order
Raises:
InvalidOrderError: If validation fails
OrderError: If submission fails
"""
# Validate if enabled
if self._validate_before_submit:
validation = self.validate_order(request)
if not validation.valid:
raise InvalidOrderError(
f"Order validation failed: {'; '.join(validation.errors)}"
)
# Submit to broker
order = await broker.submit_order(request)
# Track the order
async with self._lock:
self._orders[order.broker_order_id] = order
self._order_history[order.broker_order_id] = [
OrderStateChange(
order_id=order.broker_order_id,
from_status=None,
to_status=order.status,
event=OrderEvent.SUBMITTED,
)
]
# Trim old orders if at max
if len(self._orders) > self._max_orders:
# Remove oldest orders
sorted_orders = sorted(
self._orders.items(),
key=lambda x: x[1].created_at or datetime.min,
)
for order_id, _ in sorted_orders[: len(self._orders) - self._max_orders]:
del self._orders[order_id]
self._order_history.pop(order_id, None)
# Fire event
await self._fire_event(order, OrderEvent.SUBMITTED)
# Fire additional events based on status
if order.status == OrderStatus.FILLED:
await self._fire_event(order, OrderEvent.FILLED)
elif order.status == OrderStatus.REJECTED:
await self._fire_event(order, OrderEvent.REJECTED)
return order
async def cancel_order(
self,
order_id: str,
broker: BrokerBase,
) -> Order:
"""Cancel an order.
Args:
order_id: Order to cancel
broker: Broker to cancel through
Returns:
Cancelled order
Raises:
OrderError: If cancel fails
"""
order = await broker.cancel_order(order_id)
async with self._lock:
old_order = self._orders.get(order_id)
old_status = old_order.status if old_order else None
self._orders[order_id] = order
if order_id in self._order_history:
self._order_history[order_id].append(
OrderStateChange(
order_id=order_id,
from_status=old_status,
to_status=order.status,
event=OrderEvent.CANCELLED,
)
)
await self._fire_event(order, OrderEvent.CANCELLED)
return order
async def replace_order(
self,
order_id: str,
broker: BrokerBase,
quantity: Optional[Decimal] = None,
limit_price: Optional[Decimal] = None,
stop_price: Optional[Decimal] = None,
time_in_force: Optional[TimeInForce] = None,
) -> Order:
"""Replace an order with updated parameters.
Args:
order_id: Order to replace
broker: Broker to replace through
quantity: New quantity
limit_price: New limit price
stop_price: New stop price
time_in_force: New time in force
Returns:
New replacement order
"""
new_order = await broker.replace_order(
order_id,
quantity=quantity,
limit_price=limit_price,
stop_price=stop_price,
time_in_force=time_in_force,
)
async with self._lock:
# Mark old order as replaced
if order_id in self._orders:
old_order = self._orders[order_id]
old_order.status = OrderStatus.REPLACED
self._order_history[order_id].append(
OrderStateChange(
order_id=order_id,
from_status=old_order.status,
to_status=OrderStatus.REPLACED,
event=OrderEvent.REPLACED,
metadata={"replaced_by": new_order.broker_order_id},
)
)
# Track new order
self._orders[new_order.broker_order_id] = new_order
self._order_history[new_order.broker_order_id] = [
OrderStateChange(
order_id=new_order.broker_order_id,
from_status=None,
to_status=new_order.status,
event=OrderEvent.SUBMITTED,
metadata={"replaces": order_id},
)
]
await self._fire_event(new_order, OrderEvent.REPLACED)
return new_order
async def update_order_status(
self,
order: Order,
) -> None:
"""Update tracked order status.
Called when order status changes (e.g., from broker callbacks).
Args:
order: Order with updated status
"""
async with self._lock:
old_order = self._orders.get(order.broker_order_id)
old_status = old_order.status if old_order else None
# Validate transition
if old_status and not self.is_valid_transition(old_status, order.status):
# Log warning but allow - broker is authoritative
pass
self._orders[order.broker_order_id] = order
# Record state change
event = self._status_to_event(order.status)
if order.broker_order_id in self._order_history:
self._order_history[order.broker_order_id].append(
OrderStateChange(
order_id=order.broker_order_id,
from_status=old_status,
to_status=order.status,
event=event,
)
)
await self._fire_event(order, event)
def _status_to_event(self, status: OrderStatus) -> OrderEvent:
"""Convert order status to event type."""
mapping = {
OrderStatus.PENDING_NEW: OrderEvent.SUBMITTED,
OrderStatus.NEW: OrderEvent.ACCEPTED,
OrderStatus.PARTIALLY_FILLED: OrderEvent.PARTIALLY_FILLED,
OrderStatus.FILLED: OrderEvent.FILLED,
OrderStatus.PENDING_CANCEL: OrderEvent.PENDING_CANCEL,
OrderStatus.CANCELLED: OrderEvent.CANCELLED,
OrderStatus.REJECTED: OrderEvent.REJECTED,
OrderStatus.EXPIRED: OrderEvent.EXPIRED,
OrderStatus.REPLACED: OrderEvent.REPLACED,
}
return mapping.get(status, OrderEvent.ERROR)
def get_order(self, order_id: str) -> Optional[Order]:
"""Get tracked order by ID.
Args:
order_id: Order identifier
Returns:
Order if found, None otherwise
"""
return self._orders.get(order_id)
def get_orders(
self,
status: Optional[OrderStatus] = None,
symbol: Optional[str] = None,
side: Optional[OrderSide] = None,
) -> List[Order]:
"""Get tracked orders with optional filters.
Args:
status: Filter by status
symbol: Filter by symbol
side: Filter by side
Returns:
List of matching orders
"""
orders = list(self._orders.values())
if status:
orders = [o for o in orders if o.status == status]
if symbol:
orders = [o for o in orders if o.symbol == symbol]
if side:
orders = [o for o in orders if o.side == side]
return orders
def get_open_orders(self) -> List[Order]:
"""Get all open (non-terminal) orders.
Returns:
List of open orders
"""
return [o for o in self._orders.values() if self.is_open(o.status)]
def get_order_history(self, order_id: str) -> List[OrderStateChange]:
"""Get state change history for an order.
Args:
order_id: Order identifier
Returns:
List of state changes
"""
return self._order_history.get(order_id, [])
def clear_completed_orders(self) -> int:
"""Remove all terminal (completed) orders from tracking.
Returns:
Number of orders removed
"""
to_remove = [
order_id
for order_id, order in self._orders.items()
if self.is_terminal(order.status)
]
for order_id in to_remove:
del self._orders[order_id]
self._order_history.pop(order_id, None)
return len(to_remove)
@property
def order_count(self) -> int:
"""Get number of tracked orders."""
return len(self._orders)
@property
def open_order_count(self) -> int:
"""Get number of open orders."""
return len(self.get_open_orders())
# Export
__all__ = [
"OrderManager",
"OrderEvent",
"OrderValidationResult",
"OrderStateChange",
"OrderEventCallback",
"VALID_TRANSITIONS",
"TERMINAL_STATES",
"OPEN_STATES",
]