651 lines
20 KiB
Python
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",
|
|
]
|