TradingAgents/tradingagents/portfolio/orders.py

523 lines
17 KiB
Python

"""
Order management for the portfolio system.
This module provides various order types for executing trades, including
market orders, limit orders, stop-loss orders, and take-profit orders.
"""
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import Optional, Dict, Any
import logging
from tradingagents.security import validate_ticker
from .exceptions import (
InvalidOrderError,
InvalidPriceError,
InvalidQuantityError,
ValidationError,
)
logger = logging.getLogger(__name__)
class OrderType(Enum):
"""Enumeration of order types."""
MARKET = "market"
LIMIT = "limit"
STOP_LOSS = "stop_loss"
TAKE_PROFIT = "take_profit"
class OrderSide(Enum):
"""Enumeration of order sides."""
BUY = "buy"
SELL = "sell"
class OrderStatus(Enum):
"""Enumeration of order statuses."""
PENDING = "pending"
EXECUTED = "executed"
CANCELLED = "cancelled"
REJECTED = "rejected"
PARTIALLY_FILLED = "partially_filled"
@dataclass
class Order:
"""
Base class for all order types.
Attributes:
ticker: The security ticker symbol
quantity: Number of shares to trade (positive for buy, negative for sell)
order_type: Type of order
created_at: Timestamp when order was created
status: Current status of the order
filled_quantity: Quantity that has been filled
filled_price: Average price of filled quantity
executed_at: Timestamp when order was executed (if applicable)
metadata: Optional additional metadata
"""
ticker: str
quantity: Decimal
order_type: OrderType
created_at: datetime = field(default_factory=datetime.now)
status: OrderStatus = OrderStatus.PENDING
filled_quantity: Decimal = Decimal('0')
filled_price: Optional[Decimal] = None
executed_at: Optional[datetime] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
"""Validate order data after initialization."""
# Validate ticker
try:
self.ticker = validate_ticker(self.ticker)
except ValueError as e:
raise InvalidOrderError(f"Invalid ticker: {e}")
# Convert to Decimal if needed
if not isinstance(self.quantity, Decimal):
try:
self.quantity = Decimal(str(self.quantity))
except (ValueError, TypeError) as e:
raise InvalidQuantityError(f"Invalid quantity: {e}")
# Validate quantity is not zero
if self.quantity == 0:
raise InvalidQuantityError("Order quantity cannot be zero")
logger.info(
f"Created {self.order_type.value} order: {self.ticker} "
f"quantity={self.quantity} status={self.status.value}"
)
@property
def is_buy(self) -> bool:
"""Check if this is a buy order."""
return self.quantity > 0
@property
def is_sell(self) -> bool:
"""Check if this is a sell order."""
return self.quantity < 0
@property
def side(self) -> OrderSide:
"""Get the order side (buy or sell)."""
return OrderSide.BUY if self.is_buy else OrderSide.SELL
@property
def is_filled(self) -> bool:
"""Check if the order is fully filled."""
return self.filled_quantity == abs(self.quantity)
@property
def is_partially_filled(self) -> bool:
"""Check if the order is partially filled."""
return Decimal('0') < self.filled_quantity < abs(self.quantity)
def mark_executed(
self,
filled_quantity: Decimal,
filled_price: Decimal,
execution_time: Optional[datetime] = None
) -> None:
"""
Mark the order as executed.
Args:
filled_quantity: Quantity that was filled
filled_price: Price at which the order was filled
execution_time: Time of execution (defaults to now)
Raises:
InvalidOrderError: If the order cannot be executed
InvalidQuantityError: If filled_quantity is invalid
InvalidPriceError: If filled_price is invalid
"""
if self.status == OrderStatus.EXECUTED:
raise InvalidOrderError("Order already executed")
if self.status == OrderStatus.CANCELLED:
raise InvalidOrderError("Cannot execute cancelled order")
if not isinstance(filled_quantity, Decimal):
try:
filled_quantity = Decimal(str(filled_quantity))
except (ValueError, TypeError) as e:
raise InvalidQuantityError(f"Invalid filled quantity: {e}")
if not isinstance(filled_price, Decimal):
try:
filled_price = Decimal(str(filled_price))
except (ValueError, TypeError) as e:
raise InvalidPriceError(f"Invalid filled price: {e}")
if filled_quantity <= 0:
raise InvalidQuantityError("Filled quantity must be positive")
if filled_price <= 0:
raise InvalidPriceError("Filled price must be positive")
if filled_quantity > abs(self.quantity):
raise InvalidQuantityError(
f"Filled quantity {filled_quantity} exceeds order quantity {abs(self.quantity)}"
)
self.filled_quantity = filled_quantity
self.filled_price = filled_price
self.executed_at = execution_time or datetime.now()
if self.is_filled:
self.status = OrderStatus.EXECUTED
else:
self.status = OrderStatus.PARTIALLY_FILLED
logger.info(
f"Executed order: {self.ticker} "
f"filled_qty={filled_quantity} price={filled_price} "
f"status={self.status.value}"
)
def cancel(self) -> None:
"""
Cancel the order.
Raises:
InvalidOrderError: If the order cannot be cancelled
"""
if self.status == OrderStatus.EXECUTED:
raise InvalidOrderError("Cannot cancel executed order")
if self.status == OrderStatus.CANCELLED:
raise InvalidOrderError("Order already cancelled")
self.status = OrderStatus.CANCELLED
logger.info(f"Cancelled order: {self.ticker} quantity={self.quantity}")
def to_dict(self) -> Dict[str, Any]:
"""
Convert order to dictionary for serialization.
Returns:
Dictionary representation of the order
"""
return {
'ticker': self.ticker,
'quantity': str(self.quantity),
'order_type': self.order_type.value,
'created_at': self.created_at.isoformat(),
'status': self.status.value,
'filled_quantity': str(self.filled_quantity),
'filled_price': str(self.filled_price) if self.filled_price else None,
'executed_at': self.executed_at.isoformat() if self.executed_at else None,
'metadata': self.metadata,
}
def __repr__(self) -> str:
"""String representation of the order."""
side = "BUY" if self.is_buy else "SELL"
return (
f"Order({self.order_type.value.upper()}, {side}, {self.ticker}, "
f"qty={abs(self.quantity)}, status={self.status.value})"
)
@dataclass
class MarketOrder(Order):
"""
Market order that executes immediately at the current market price.
A market order is guaranteed to execute (assuming sufficient liquidity)
but the price is not guaranteed.
Example:
>>> order = MarketOrder('AAPL', Decimal('100')) # Buy 100 shares at market
>>> order = MarketOrder('AAPL', Decimal('-50')) # Sell 50 shares at market
"""
order_type: OrderType = field(default=OrderType.MARKET, init=False)
def can_execute(self, current_price: Decimal) -> bool:
"""
Check if the order can be executed at the current price.
Market orders can always be executed.
Args:
current_price: Current market price
Returns:
Always True for market orders
"""
return True
@dataclass
class LimitOrder(Order):
"""
Limit order that only executes at a specified price or better.
For buy orders: executes at limit_price or lower
For sell orders: executes at limit_price or higher
Attributes:
limit_price: The price limit for the order
Example:
>>> order = LimitOrder('AAPL', Decimal('100'), limit_price=Decimal('150.00'))
>>> # Buy 100 shares only if price is <= $150.00
"""
limit_price: Decimal = None
order_type: OrderType = field(default=OrderType.LIMIT, init=False)
def __post_init__(self):
"""Validate limit order data."""
super().__post_init__()
if self.limit_price is None:
raise InvalidOrderError("Limit price is required for limit orders")
if not isinstance(self.limit_price, Decimal):
try:
self.limit_price = Decimal(str(self.limit_price))
except (ValueError, TypeError) as e:
raise InvalidPriceError(f"Invalid limit price: {e}")
if self.limit_price <= 0:
raise InvalidPriceError("Limit price must be positive")
def can_execute(self, current_price: Decimal) -> bool:
"""
Check if the order can be executed at the current price.
Args:
current_price: Current market price
Returns:
True if the order can be executed at current price
Raises:
InvalidPriceError: If current_price is invalid
"""
if not isinstance(current_price, Decimal):
try:
current_price = Decimal(str(current_price))
except (ValueError, TypeError) as e:
raise InvalidPriceError(f"Invalid current price: {e}")
if current_price <= 0:
raise InvalidPriceError("Current price must be positive")
if self.is_buy:
# Buy order executes if current price is at or below limit
return current_price <= self.limit_price
else:
# Sell order executes if current price is at or above limit
return current_price >= self.limit_price
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary with limit price."""
data = super().to_dict()
data['limit_price'] = str(self.limit_price)
return data
@dataclass
class StopLossOrder(Order):
"""
Stop-loss order that triggers when price reaches a specified level.
Used to limit losses by automatically closing a position when
the price moves against you.
For long positions: triggers when price falls to or below stop_price
For short positions: triggers when price rises to or above stop_price
Attributes:
stop_price: The price at which the order triggers
Example:
>>> order = StopLossOrder('AAPL', Decimal('-100'), stop_price=Decimal('145.00'))
>>> # Sell 100 shares if price drops to or below $145.00
"""
stop_price: Decimal = None
order_type: OrderType = field(default=OrderType.STOP_LOSS, init=False)
def __post_init__(self):
"""Validate stop-loss order data."""
super().__post_init__()
if self.stop_price is None:
raise InvalidOrderError("Stop price is required for stop-loss orders")
if not isinstance(self.stop_price, Decimal):
try:
self.stop_price = Decimal(str(self.stop_price))
except (ValueError, TypeError) as e:
raise InvalidPriceError(f"Invalid stop price: {e}")
if self.stop_price <= 0:
raise InvalidPriceError("Stop price must be positive")
def can_execute(self, current_price: Decimal) -> bool:
"""
Check if the stop-loss should be triggered.
Args:
current_price: Current market price
Returns:
True if stop-loss should trigger
Raises:
InvalidPriceError: If current_price is invalid
"""
if not isinstance(current_price, Decimal):
try:
current_price = Decimal(str(current_price))
except (ValueError, TypeError) as e:
raise InvalidPriceError(f"Invalid current price: {e}")
if current_price <= 0:
raise InvalidPriceError("Current price must be positive")
# Stop-loss for closing long positions (sell order)
if self.is_sell:
return current_price <= self.stop_price
# Stop-loss for closing short positions (buy order)
else:
return current_price >= self.stop_price
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary with stop price."""
data = super().to_dict()
data['stop_price'] = str(self.stop_price)
return data
@dataclass
class TakeProfitOrder(Order):
"""
Take-profit order that triggers when price reaches a profit target.
Used to lock in profits by automatically closing a position when
the price reaches a favorable level.
For long positions: triggers when price rises to or above target_price
For short positions: triggers when price falls to or below target_price
Attributes:
target_price: The price at which the order triggers
Example:
>>> order = TakeProfitOrder('AAPL', Decimal('-100'), target_price=Decimal('160.00'))
>>> # Sell 100 shares if price rises to or above $160.00
"""
target_price: Decimal = None
order_type: OrderType = field(default=OrderType.TAKE_PROFIT, init=False)
def __post_init__(self):
"""Validate take-profit order data."""
super().__post_init__()
if self.target_price is None:
raise InvalidOrderError("Target price is required for take-profit orders")
if not isinstance(self.target_price, Decimal):
try:
self.target_price = Decimal(str(self.target_price))
except (ValueError, TypeError) as e:
raise InvalidPriceError(f"Invalid target price: {e}")
if self.target_price <= 0:
raise InvalidPriceError("Target price must be positive")
def can_execute(self, current_price: Decimal) -> bool:
"""
Check if the take-profit should be triggered.
Args:
current_price: Current market price
Returns:
True if take-profit should trigger
Raises:
InvalidPriceError: If current_price is invalid
"""
if not isinstance(current_price, Decimal):
try:
current_price = Decimal(str(current_price))
except (ValueError, TypeError) as e:
raise InvalidPriceError(f"Invalid current price: {e}")
if current_price <= 0:
raise InvalidPriceError("Current price must be positive")
# Take-profit for closing long positions (sell order)
if self.is_sell:
return current_price >= self.target_price
# Take-profit for closing short positions (buy order)
else:
return current_price <= self.target_price
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary with target price."""
data = super().to_dict()
data['target_price'] = str(self.target_price)
return data
def create_order_from_dict(data: Dict[str, Any]) -> Order:
"""
Create an order from a dictionary.
Args:
data: Dictionary containing order data
Returns:
Order instance of the appropriate type
Raises:
ValidationError: If data is invalid
"""
try:
order_type = OrderType(data['order_type'])
base_args = {
'ticker': data['ticker'],
'quantity': Decimal(data['quantity']),
'created_at': datetime.fromisoformat(data['created_at']),
'status': OrderStatus(data['status']),
'filled_quantity': Decimal(data['filled_quantity']),
'filled_price': Decimal(data['filled_price']) if data.get('filled_price') else None,
'executed_at': datetime.fromisoformat(data['executed_at']) if data.get('executed_at') else None,
'metadata': data.get('metadata', {}),
}
if order_type == OrderType.MARKET:
return MarketOrder(**base_args)
elif order_type == OrderType.LIMIT:
base_args['limit_price'] = Decimal(data['limit_price'])
return LimitOrder(**base_args)
elif order_type == OrderType.STOP_LOSS:
base_args['stop_price'] = Decimal(data['stop_price'])
return StopLossOrder(**base_args)
elif order_type == OrderType.TAKE_PROFIT:
base_args['target_price'] = Decimal(data['target_price'])
return TakeProfitOrder(**base_args)
else:
raise ValidationError(f"Unknown order type: {order_type}")
except (KeyError, ValueError, TypeError) as e:
raise ValidationError(f"Invalid order data: {e}")