523 lines
17 KiB
Python
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}")
|