TradingAgents/tradingagents/backtest/execution.py

583 lines
16 KiB
Python

"""
Execution simulation for backtesting.
This module simulates realistic order execution including slippage,
commissions, market impact, and partial fills.
"""
import logging
from dataclasses import dataclass
from datetime import datetime, time
from decimal import Decimal
from enum import Enum
from typing import Optional, Dict, Any
import random
import pandas as pd
import numpy as np
from .config import BacktestConfig, OrderType, SlippageModel, CommissionModel
from .exceptions import (
ExecutionError,
InsufficientCapitalError,
InvalidOrderError,
)
logger = logging.getLogger(__name__)
class OrderSide(Enum):
"""Order side (buy or sell)."""
BUY = "buy"
SELL = "sell"
class OrderStatus(Enum):
"""Order execution status."""
PENDING = "pending"
FILLED = "filled"
PARTIALLY_FILLED = "partially_filled"
REJECTED = "rejected"
CANCELLED = "cancelled"
@dataclass
class Order:
"""
Represents a trading order.
Attributes:
ticker: Security ticker
side: Buy or sell
quantity: Number of shares
order_type: Type of order
timestamp: Order timestamp
limit_price: Limit price (for limit orders)
stop_price: Stop price (for stop orders)
filled_quantity: Quantity filled
filled_price: Average fill price
commission: Commission paid
slippage: Slippage cost
status: Order status
"""
ticker: str
side: OrderSide
quantity: Decimal
order_type: OrderType
timestamp: datetime
limit_price: Optional[Decimal] = None
stop_price: Optional[Decimal] = None
filled_quantity: Decimal = Decimal("0")
filled_price: Decimal = Decimal("0")
commission: Decimal = Decimal("0")
slippage: Decimal = Decimal("0")
status: OrderStatus = OrderStatus.PENDING
def __post_init__(self):
"""Validate order."""
if self.quantity <= 0:
raise InvalidOrderError("Order quantity must be positive")
if isinstance(self.side, str):
self.side = OrderSide(self.side)
if isinstance(self.order_type, str):
self.order_type = OrderType(self.order_type)
if isinstance(self.status, str):
self.status = OrderStatus(self.status)
@property
def is_filled(self) -> bool:
"""Check if order is fully filled."""
return self.status == OrderStatus.FILLED
@property
def is_partially_filled(self) -> bool:
"""Check if order is partially filled."""
return self.status == OrderStatus.PARTIALLY_FILLED
@property
def remaining_quantity(self) -> Decimal:
"""Get remaining quantity to fill."""
return self.quantity - self.filled_quantity
def to_dict(self) -> Dict[str, Any]:
"""Convert order to dictionary."""
return {
'ticker': self.ticker,
'side': self.side.value,
'quantity': float(self.quantity),
'order_type': self.order_type.value,
'timestamp': self.timestamp,
'limit_price': float(self.limit_price) if self.limit_price else None,
'stop_price': float(self.stop_price) if self.stop_price else None,
'filled_quantity': float(self.filled_quantity),
'filled_price': float(self.filled_price),
'commission': float(self.commission),
'slippage': float(self.slippage),
'status': self.status.value,
}
@dataclass
class Fill:
"""
Represents an order fill.
Attributes:
order_id: Associated order ID
ticker: Security ticker
side: Buy or sell
quantity: Filled quantity
price: Fill price
timestamp: Fill timestamp
commission: Commission paid
slippage: Slippage cost
"""
order_id: int
ticker: str
side: OrderSide
quantity: Decimal
price: Decimal
timestamp: datetime
commission: Decimal = Decimal("0")
slippage: Decimal = Decimal("0")
def to_dict(self) -> Dict[str, Any]:
"""Convert fill to dictionary."""
return {
'order_id': self.order_id,
'ticker': self.ticker,
'side': self.side.value if isinstance(self.side, OrderSide) else self.side,
'quantity': float(self.quantity),
'price': float(self.price),
'timestamp': self.timestamp,
'commission': float(self.commission),
'slippage': float(self.slippage),
}
class ExecutionSimulator:
"""
Simulates realistic order execution.
This class models slippage, commissions, market impact, and other
execution costs to create realistic backtesting.
Attributes:
config: Backtest configuration
fills: List of all fills
order_count: Counter for order IDs
"""
def __init__(self, config: BacktestConfig):
"""
Initialize execution simulator.
Args:
config: Backtest configuration
"""
self.config = config
self.fills: list[Fill] = []
self.order_count = 0
# Set random seed for reproducibility
if config.random_seed is not None:
random.seed(config.random_seed)
np.random.seed(config.random_seed)
logger.info("ExecutionSimulator initialized")
def execute_order(
self,
order: Order,
current_price: Decimal,
current_volume: Decimal,
available_capital: Decimal,
) -> Order:
"""
Execute an order.
Args:
order: Order to execute
current_price: Current market price
current_volume: Current trading volume
available_capital: Available capital
Returns:
Updated order with fill information
Raises:
InsufficientCapitalError: If insufficient capital
ExecutionError: If execution fails
"""
self.order_count += 1
# Check trading hours
if self.config.trading_hours and not self._is_market_open(order.timestamp):
order.status = OrderStatus.REJECTED
logger.warning(f"Order rejected - market closed at {order.timestamp}")
return order
# Determine if order can be filled
if not self._can_fill_order(order, current_price):
order.status = OrderStatus.REJECTED
logger.debug(f"Order rejected - price conditions not met")
return order
# Calculate fill price with slippage
fill_price = self._calculate_fill_price(
order,
current_price,
current_volume
)
# Calculate quantity to fill
fill_quantity = order.quantity
# Handle partial fills
if self.config.partial_fills:
fill_quantity = self._calculate_partial_fill(
order.quantity,
current_volume
)
# Check capital requirements
if order.side == OrderSide.BUY:
required_capital = fill_quantity * fill_price
commission = self._calculate_commission(fill_quantity, fill_price)
total_required = required_capital + commission
if total_required > available_capital:
if self.config.partial_fills:
# Fill what we can afford
affordable_quantity = available_capital / (fill_price * (Decimal("1") + self.config.commission))
fill_quantity = min(fill_quantity, affordable_quantity.quantize(Decimal("1")))
if fill_quantity <= 0:
order.status = OrderStatus.REJECTED
raise InsufficientCapitalError(
f"Insufficient capital: need {total_required}, have {available_capital}"
)
else:
order.status = OrderStatus.REJECTED
raise InsufficientCapitalError(
f"Insufficient capital: need {total_required}, have {available_capital}"
)
# Calculate final costs
commission = self._calculate_commission(fill_quantity, fill_price)
slippage_cost = abs(fill_price - current_price) * fill_quantity
# Update order
order.filled_quantity = fill_quantity
order.filled_price = fill_price
order.commission = commission
order.slippage = slippage_cost
if fill_quantity >= order.quantity:
order.status = OrderStatus.FILLED
else:
order.status = OrderStatus.PARTIALLY_FILLED
# Record fill
fill = Fill(
order_id=self.order_count,
ticker=order.ticker,
side=order.side,
quantity=fill_quantity,
price=fill_price,
timestamp=order.timestamp,
commission=commission,
slippage=slippage_cost,
)
self.fills.append(fill)
logger.debug(
f"Order executed: {order.ticker} {order.side.value} "
f"{fill_quantity} @ {fill_price} (comm: {commission}, slip: {slippage_cost})"
)
return order
def _can_fill_order(self, order: Order, current_price: Decimal) -> bool:
"""
Check if order can be filled at current price.
Args:
order: Order to check
current_price: Current market price
Returns:
True if order can be filled
"""
if order.order_type == OrderType.MARKET:
return True
elif order.order_type == OrderType.LIMIT:
if order.side == OrderSide.BUY:
return current_price <= order.limit_price
else:
return current_price >= order.limit_price
elif order.order_type == OrderType.STOP:
if order.side == OrderSide.BUY:
return current_price >= order.stop_price
else:
return current_price <= order.stop_price
return False
def _calculate_fill_price(
self,
order: Order,
current_price: Decimal,
current_volume: Decimal
) -> Decimal:
"""
Calculate fill price including slippage.
Args:
order: Order being filled
current_price: Current market price
current_volume: Current trading volume
Returns:
Fill price including slippage
"""
base_price = current_price
# Calculate slippage
if self.config.slippage_model == SlippageModel.FIXED:
slippage = self._calculate_fixed_slippage(order, base_price)
elif self.config.slippage_model == SlippageModel.VOLUME_BASED:
slippage = self._calculate_volume_slippage(
order, base_price, current_volume
)
elif self.config.slippage_model == SlippageModel.SPREAD_BASED:
slippage = self._calculate_spread_slippage(order, base_price)
else:
slippage = Decimal("0")
# Apply slippage
if order.side == OrderSide.BUY:
fill_price = base_price * (Decimal("1") + slippage)
else:
fill_price = base_price * (Decimal("1") - slippage)
return fill_price
def _calculate_fixed_slippage(
self,
order: Order,
base_price: Decimal
) -> Decimal:
"""Calculate fixed percentage slippage."""
return self.config.slippage
def _calculate_volume_slippage(
self,
order: Order,
base_price: Decimal,
current_volume: Decimal
) -> Decimal:
"""Calculate volume-based slippage."""
if current_volume == 0:
return self.config.slippage * Decimal("2") # Penalty for low volume
# Slippage increases with order size relative to volume
volume_ratio = order.quantity / current_volume
volume_impact = volume_ratio * Decimal("0.1") # 10% impact per 1% of volume
return self.config.slippage + volume_impact
def _calculate_spread_slippage(
self,
order: Order,
base_price: Decimal
) -> Decimal:
"""Calculate spread-based slippage."""
# Assume bid-ask spread is 2x the configured slippage
spread = self.config.slippage * Decimal("2")
return spread / Decimal("2") # Half spread
def _calculate_commission(
self,
quantity: Decimal,
price: Decimal
) -> Decimal:
"""
Calculate commission for a trade.
Args:
quantity: Trade quantity
price: Trade price
Returns:
Commission amount
"""
if self.config.commission_model == CommissionModel.PERCENTAGE:
return quantity * price * self.config.commission
elif self.config.commission_model == CommissionModel.PER_SHARE:
return quantity * self.config.commission
elif self.config.commission_model == CommissionModel.FIXED_PER_TRADE:
return self.config.commission
else:
return Decimal("0")
def _calculate_partial_fill(
self,
order_quantity: Decimal,
current_volume: Decimal
) -> Decimal:
"""
Calculate partial fill quantity.
Args:
order_quantity: Requested quantity
current_volume: Current market volume
Returns:
Quantity that can be filled
"""
if current_volume == 0:
return Decimal("0")
# Can fill up to 10% of daily volume
max_fillable = current_volume * Decimal("0.1")
# Add randomness
fill_ratio = Decimal(str(random.uniform(0.5, 1.0)))
fillable = min(order_quantity, max_fillable) * fill_ratio
return fillable.quantize(Decimal("1"))
def _is_market_open(self, timestamp: datetime) -> bool:
"""
Check if market is open at timestamp.
Args:
timestamp: Time to check
Returns:
True if market is open
"""
if not self.config.trading_hours:
return True
# Get day of week (0 = Monday, 6 = Sunday)
day_of_week = timestamp.weekday()
# Check if weekend
if day_of_week >= 5: # Saturday or Sunday
return False
# Check trading hours (default: 9:30 AM - 4:00 PM ET)
market_open = self.config.trading_hours.get('open', time(9, 30))
market_close = self.config.trading_hours.get('close', time(16, 0))
current_time = timestamp.time()
return market_open <= current_time <= market_close
def get_fills_df(self) -> pd.DataFrame:
"""
Get fills as DataFrame.
Returns:
DataFrame with all fills
"""
if not self.fills:
return pd.DataFrame()
return pd.DataFrame([fill.to_dict() for fill in self.fills])
def get_total_commission(self) -> Decimal:
"""
Get total commission paid.
Returns:
Total commission
"""
return sum(fill.commission for fill in self.fills)
def get_total_slippage(self) -> Decimal:
"""
Get total slippage cost.
Returns:
Total slippage
"""
return sum(fill.slippage for fill in self.fills)
def reset(self) -> None:
"""Reset the execution simulator."""
self.fills = []
self.order_count = 0
logger.info("ExecutionSimulator reset")
def create_market_order(
ticker: str,
side: OrderSide,
quantity: Decimal,
timestamp: datetime
) -> Order:
"""
Create a market order.
Args:
ticker: Security ticker
side: Buy or sell
quantity: Quantity
timestamp: Order timestamp
Returns:
Market order
"""
return Order(
ticker=ticker,
side=side,
quantity=quantity,
order_type=OrderType.MARKET,
timestamp=timestamp,
)
def create_limit_order(
ticker: str,
side: OrderSide,
quantity: Decimal,
limit_price: Decimal,
timestamp: datetime
) -> Order:
"""
Create a limit order.
Args:
ticker: Security ticker
side: Buy or sell
quantity: Quantity
limit_price: Limit price
timestamp: Order timestamp
Returns:
Limit order
"""
return Order(
ticker=ticker,
side=side,
quantity=quantity,
order_type=OrderType.LIMIT,
limit_price=limit_price,
timestamp=timestamp,
)