918 lines
32 KiB
Python
918 lines
32 KiB
Python
"""Signal to Order Converter.
|
|
|
|
This module converts trading signals into executable orders by:
|
|
- Translating BUY/SELL signals to OrderRequest objects
|
|
- Applying position sizing based on risk parameters
|
|
- Setting stop loss and take profit levels
|
|
- Validating orders before submission
|
|
|
|
Issue #36: [STRAT-35] Signal to order converter
|
|
|
|
Design Principles:
|
|
- Clean separation between signal generation and execution
|
|
- Risk-aware position sizing
|
|
- Configurable stop loss and take profit
|
|
- Comprehensive order validation
|
|
"""
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from decimal import Decimal, ROUND_DOWN
|
|
from enum import Enum
|
|
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
import uuid
|
|
|
|
from tradingagents.execution.broker_base import (
|
|
OrderRequest,
|
|
OrderSide,
|
|
OrderType,
|
|
TimeInForce,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Enums
|
|
# ============================================================================
|
|
|
|
class SignalType(str, Enum):
|
|
"""Type of trading signal."""
|
|
BUY = "buy" # Long entry
|
|
SELL = "sell" # Long exit or short entry
|
|
HOLD = "hold" # No action
|
|
CLOSE = "close" # Close existing position
|
|
SCALE_IN = "scale_in" # Add to position
|
|
SCALE_OUT = "scale_out" # Reduce position
|
|
|
|
|
|
class SignalStrength(str, Enum):
|
|
"""Strength of the signal."""
|
|
STRONG = "strong" # High confidence, full size
|
|
MODERATE = "moderate" # Medium confidence, partial size
|
|
WEAK = "weak" # Low confidence, minimal size
|
|
|
|
|
|
class PositionSizingMethod(str, Enum):
|
|
"""Method for calculating position size."""
|
|
FIXED_QUANTITY = "fixed_quantity" # Fixed number of shares
|
|
FIXED_VALUE = "fixed_value" # Fixed dollar amount
|
|
PERCENT_PORTFOLIO = "percent_portfolio" # Percentage of portfolio
|
|
RISK_BASED = "risk_based" # Based on max risk per trade
|
|
VOLATILITY_BASED = "volatility_based" # Based on asset volatility
|
|
|
|
|
|
class StopLossType(str, Enum):
|
|
"""Type of stop loss."""
|
|
FIXED_PERCENT = "fixed_percent" # Fixed percentage below entry
|
|
FIXED_AMOUNT = "fixed_amount" # Fixed dollar amount below entry
|
|
ATR_BASED = "atr_based" # Multiple of ATR
|
|
VOLATILITY_BASED = "volatility_based" # Based on historical volatility
|
|
SUPPORT_LEVEL = "support_level" # At support level
|
|
TRAILING = "trailing" # Trailing stop
|
|
|
|
|
|
class TakeProfitType(str, Enum):
|
|
"""Type of take profit."""
|
|
FIXED_PERCENT = "fixed_percent" # Fixed percentage above entry
|
|
FIXED_AMOUNT = "fixed_amount" # Fixed dollar amount above entry
|
|
RISK_REWARD = "risk_reward" # Multiple of risk (R:R ratio)
|
|
RESISTANCE_LEVEL = "resistance_level" # At resistance level
|
|
TRAILING = "trailing" # Trailing take profit
|
|
|
|
|
|
class OrderValidationError(str, Enum):
|
|
"""Order validation error types."""
|
|
INVALID_SYMBOL = "invalid_symbol"
|
|
INVALID_QUANTITY = "invalid_quantity"
|
|
INVALID_PRICE = "invalid_price"
|
|
INSUFFICIENT_CAPITAL = "insufficient_capital"
|
|
POSITION_SIZE_EXCEEDED = "position_size_exceeded"
|
|
RISK_LIMIT_EXCEEDED = "risk_limit_exceeded"
|
|
INVALID_STOP_LOSS = "invalid_stop_loss"
|
|
INVALID_TAKE_PROFIT = "invalid_take_profit"
|
|
|
|
|
|
# ============================================================================
|
|
# Data Classes
|
|
# ============================================================================
|
|
|
|
@dataclass
|
|
class TradingSignal:
|
|
"""A trading signal from strategy or analyst.
|
|
|
|
Attributes:
|
|
signal_id: Unique identifier for the signal
|
|
timestamp: When the signal was generated
|
|
symbol: Trading symbol
|
|
signal_type: Type of signal (buy, sell, hold, etc.)
|
|
strength: Signal strength (strong, moderate, weak)
|
|
entry_price: Suggested entry price
|
|
target_price: Suggested target price
|
|
stop_price: Suggested stop price
|
|
confidence: Confidence level (0-1)
|
|
reason: Human-readable reason for signal
|
|
metadata: Additional signal data
|
|
"""
|
|
signal_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
timestamp: datetime = field(default_factory=datetime.now)
|
|
symbol: str = ""
|
|
signal_type: SignalType = SignalType.HOLD
|
|
strength: SignalStrength = SignalStrength.MODERATE
|
|
entry_price: Optional[Decimal] = None
|
|
target_price: Optional[Decimal] = None
|
|
stop_price: Optional[Decimal] = None
|
|
confidence: Decimal = Decimal("0.5")
|
|
reason: str = ""
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class PositionSizingConfig:
|
|
"""Configuration for position sizing.
|
|
|
|
Attributes:
|
|
method: Position sizing method
|
|
fixed_quantity: Fixed quantity for FIXED_QUANTITY method
|
|
fixed_value: Fixed value for FIXED_VALUE method
|
|
percent_portfolio: Percentage for PERCENT_PORTFOLIO method
|
|
max_risk_per_trade: Max risk per trade (as decimal, e.g., 0.01 = 1%)
|
|
max_position_percent: Max position size as % of portfolio
|
|
round_to_lot_size: Whether to round to lot sizes
|
|
lot_size: Lot size for rounding (default 1)
|
|
min_quantity: Minimum order quantity
|
|
max_quantity: Maximum order quantity
|
|
"""
|
|
method: PositionSizingMethod = PositionSizingMethod.PERCENT_PORTFOLIO
|
|
fixed_quantity: Decimal = Decimal("100")
|
|
fixed_value: Decimal = Decimal("10000")
|
|
percent_portfolio: Decimal = Decimal("0.05") # 5%
|
|
max_risk_per_trade: Decimal = Decimal("0.01") # 1%
|
|
max_position_percent: Decimal = Decimal("0.20") # 20%
|
|
round_to_lot_size: bool = True
|
|
lot_size: Decimal = Decimal("1")
|
|
min_quantity: Decimal = Decimal("1")
|
|
max_quantity: Optional[Decimal] = None
|
|
|
|
|
|
@dataclass
|
|
class StopLossConfig:
|
|
"""Configuration for stop loss.
|
|
|
|
Attributes:
|
|
type: Stop loss type
|
|
percent: Percentage for FIXED_PERCENT type
|
|
amount: Dollar amount for FIXED_AMOUNT type
|
|
atr_multiplier: ATR multiplier for ATR_BASED type
|
|
trail_percent: Trail percentage for TRAILING type
|
|
trail_amount: Trail amount for TRAILING type
|
|
enabled: Whether stop loss is enabled
|
|
"""
|
|
type: StopLossType = StopLossType.FIXED_PERCENT
|
|
percent: Decimal = Decimal("0.02") # 2%
|
|
amount: Optional[Decimal] = None
|
|
atr_multiplier: Decimal = Decimal("2.0")
|
|
trail_percent: Optional[Decimal] = None
|
|
trail_amount: Optional[Decimal] = None
|
|
enabled: bool = True
|
|
|
|
|
|
@dataclass
|
|
class TakeProfitConfig:
|
|
"""Configuration for take profit.
|
|
|
|
Attributes:
|
|
type: Take profit type
|
|
percent: Percentage for FIXED_PERCENT type
|
|
amount: Dollar amount for FIXED_AMOUNT type
|
|
risk_reward_ratio: Risk:reward ratio for RISK_REWARD type
|
|
enabled: Whether take profit is enabled
|
|
"""
|
|
type: TakeProfitType = TakeProfitType.RISK_REWARD
|
|
percent: Decimal = Decimal("0.06") # 6%
|
|
amount: Optional[Decimal] = None
|
|
risk_reward_ratio: Decimal = Decimal("3.0") # 3:1 R:R
|
|
enabled: bool = True
|
|
|
|
|
|
@dataclass
|
|
class ConversionConfig:
|
|
"""Configuration for signal to order conversion.
|
|
|
|
Attributes:
|
|
position_sizing: Position sizing configuration
|
|
stop_loss: Stop loss configuration
|
|
take_profit: Take profit configuration
|
|
default_order_type: Default order type
|
|
default_time_in_force: Default time in force
|
|
use_limit_orders: Use limit orders instead of market
|
|
limit_order_offset: Offset from signal price for limits
|
|
scale_by_strength: Scale position size by signal strength
|
|
strength_multipliers: Multipliers for each signal strength
|
|
scale_by_confidence: Scale position size by confidence
|
|
min_confidence: Minimum confidence to generate order
|
|
"""
|
|
position_sizing: PositionSizingConfig = field(
|
|
default_factory=PositionSizingConfig
|
|
)
|
|
stop_loss: StopLossConfig = field(default_factory=StopLossConfig)
|
|
take_profit: TakeProfitConfig = field(default_factory=TakeProfitConfig)
|
|
default_order_type: OrderType = OrderType.MARKET
|
|
default_time_in_force: TimeInForce = TimeInForce.DAY
|
|
use_limit_orders: bool = False
|
|
limit_order_offset: Decimal = Decimal("0.001") # 0.1%
|
|
scale_by_strength: bool = True
|
|
strength_multipliers: Dict[SignalStrength, Decimal] = field(
|
|
default_factory=lambda: {
|
|
SignalStrength.STRONG: Decimal("1.0"),
|
|
SignalStrength.MODERATE: Decimal("0.75"),
|
|
SignalStrength.WEAK: Decimal("0.5"),
|
|
}
|
|
)
|
|
scale_by_confidence: bool = False
|
|
min_confidence: Decimal = Decimal("0.0")
|
|
|
|
|
|
@dataclass
|
|
class OrderValidationResult:
|
|
"""Result of order validation.
|
|
|
|
Attributes:
|
|
is_valid: Whether the order is valid
|
|
errors: List of validation errors
|
|
warnings: List of validation warnings
|
|
adjusted_quantity: Quantity after adjustments (if any)
|
|
adjusted_stop_price: Stop price after adjustments
|
|
adjusted_take_profit: Take profit after adjustments
|
|
"""
|
|
is_valid: bool = True
|
|
errors: List[Tuple[OrderValidationError, str]] = field(default_factory=list)
|
|
warnings: List[str] = field(default_factory=list)
|
|
adjusted_quantity: Optional[Decimal] = None
|
|
adjusted_stop_price: Optional[Decimal] = None
|
|
adjusted_take_profit: Optional[Decimal] = None
|
|
|
|
|
|
@dataclass
|
|
class ConversionResult:
|
|
"""Result of signal to order conversion.
|
|
|
|
Attributes:
|
|
success: Whether conversion succeeded
|
|
order_request: The generated OrderRequest (if successful)
|
|
stop_loss_order: Stop loss order (if configured)
|
|
take_profit_order: Take profit order (if configured)
|
|
validation: Validation result
|
|
signal: Original signal
|
|
calculated_quantity: Position size calculated
|
|
calculated_stop_price: Stop loss price calculated
|
|
calculated_take_profit: Take profit price calculated
|
|
error_message: Error message (if failed)
|
|
"""
|
|
success: bool = True
|
|
order_request: Optional[OrderRequest] = None
|
|
stop_loss_order: Optional[OrderRequest] = None
|
|
take_profit_order: Optional[OrderRequest] = None
|
|
validation: OrderValidationResult = field(
|
|
default_factory=OrderValidationResult
|
|
)
|
|
signal: Optional[TradingSignal] = None
|
|
calculated_quantity: Decimal = Decimal("0")
|
|
calculated_stop_price: Optional[Decimal] = None
|
|
calculated_take_profit: Optional[Decimal] = None
|
|
error_message: str = ""
|
|
|
|
|
|
# ============================================================================
|
|
# SignalToOrderConverter Class
|
|
# ============================================================================
|
|
|
|
class SignalToOrderConverter:
|
|
"""Converts trading signals to executable orders.
|
|
|
|
This class handles the conversion of trading signals into OrderRequest
|
|
objects, applying position sizing, stop loss, and take profit logic.
|
|
|
|
Attributes:
|
|
config: Conversion configuration
|
|
portfolio_value: Current portfolio value (for sizing)
|
|
current_prices: Dict of symbol to current price
|
|
volatility_data: Dict of symbol to volatility (for ATR-based stops)
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
config: Optional[ConversionConfig] = None,
|
|
portfolio_value: Decimal = Decimal("100000"),
|
|
current_prices: Optional[Dict[str, Decimal]] = None,
|
|
volatility_data: Optional[Dict[str, Decimal]] = None,
|
|
):
|
|
"""Initialize the converter.
|
|
|
|
Args:
|
|
config: Conversion configuration (uses defaults if None)
|
|
portfolio_value: Current portfolio value
|
|
current_prices: Dict of symbol to current market price
|
|
volatility_data: Dict of symbol to ATR or volatility
|
|
"""
|
|
self.config = config or ConversionConfig()
|
|
self.portfolio_value = portfolio_value
|
|
self.current_prices = current_prices or {}
|
|
self.volatility_data = volatility_data or {}
|
|
|
|
def convert(self, signal: TradingSignal) -> ConversionResult:
|
|
"""Convert a trading signal to an order.
|
|
|
|
Args:
|
|
signal: The trading signal to convert
|
|
|
|
Returns:
|
|
ConversionResult with order(s) and validation info
|
|
"""
|
|
result = ConversionResult(signal=signal)
|
|
|
|
# Skip non-actionable signals
|
|
if signal.signal_type == SignalType.HOLD:
|
|
result.success = False
|
|
result.error_message = "HOLD signals do not generate orders"
|
|
return result
|
|
|
|
# Check minimum confidence
|
|
if signal.confidence < self.config.min_confidence:
|
|
result.success = False
|
|
result.error_message = (
|
|
f"Signal confidence {signal.confidence} below minimum "
|
|
f"{self.config.min_confidence}"
|
|
)
|
|
return result
|
|
|
|
# Get entry price
|
|
entry_price = self._get_entry_price(signal)
|
|
if entry_price is None:
|
|
result.success = False
|
|
result.error_message = (
|
|
f"Cannot determine entry price for {signal.symbol}"
|
|
)
|
|
return result
|
|
|
|
# Calculate position size
|
|
quantity = self._calculate_position_size(
|
|
signal=signal,
|
|
entry_price=entry_price,
|
|
)
|
|
result.calculated_quantity = quantity
|
|
|
|
# Calculate stop loss
|
|
stop_price = None
|
|
if self.config.stop_loss.enabled:
|
|
stop_price = self._calculate_stop_loss(
|
|
signal=signal,
|
|
entry_price=entry_price,
|
|
)
|
|
result.calculated_stop_price = stop_price
|
|
|
|
# Calculate take profit
|
|
take_profit_price = None
|
|
if self.config.take_profit.enabled:
|
|
take_profit_price = self._calculate_take_profit(
|
|
signal=signal,
|
|
entry_price=entry_price,
|
|
stop_price=stop_price,
|
|
)
|
|
result.calculated_take_profit = take_profit_price
|
|
|
|
# Validate the order
|
|
validation = self._validate_order(
|
|
signal=signal,
|
|
quantity=quantity,
|
|
entry_price=entry_price,
|
|
stop_price=stop_price,
|
|
take_profit_price=take_profit_price,
|
|
)
|
|
result.validation = validation
|
|
|
|
if not validation.is_valid:
|
|
result.success = False
|
|
result.error_message = "; ".join(
|
|
f"{e.value}: {msg}" for e, msg in validation.errors
|
|
)
|
|
return result
|
|
|
|
# Use adjusted values if available
|
|
final_quantity = validation.adjusted_quantity or quantity
|
|
final_stop = validation.adjusted_stop_price or stop_price
|
|
final_tp = validation.adjusted_take_profit or take_profit_price
|
|
|
|
# Determine order side
|
|
order_side = self._signal_to_side(signal.signal_type)
|
|
|
|
# Determine order type and price
|
|
order_type, limit_price = self._determine_order_type(
|
|
signal=signal,
|
|
entry_price=entry_price,
|
|
)
|
|
|
|
# Create main order
|
|
try:
|
|
main_order = OrderRequest(
|
|
symbol=signal.symbol,
|
|
side=order_side,
|
|
quantity=final_quantity,
|
|
order_type=order_type,
|
|
limit_price=limit_price,
|
|
time_in_force=self.config.default_time_in_force,
|
|
stop_loss_price=final_stop if self.config.stop_loss.enabled else None,
|
|
take_profit_price=final_tp if self.config.take_profit.enabled else None,
|
|
metadata={
|
|
"signal_id": signal.signal_id,
|
|
"signal_strength": signal.strength.value,
|
|
"signal_confidence": str(signal.confidence),
|
|
"signal_reason": signal.reason,
|
|
},
|
|
)
|
|
result.order_request = main_order
|
|
except ValueError as e:
|
|
result.success = False
|
|
result.error_message = f"Failed to create order: {str(e)}"
|
|
return result
|
|
|
|
# Create separate stop loss order if needed
|
|
if final_stop and order_side == OrderSide.BUY:
|
|
try:
|
|
result.stop_loss_order = OrderRequest(
|
|
symbol=signal.symbol,
|
|
side=OrderSide.SELL,
|
|
quantity=final_quantity,
|
|
order_type=OrderType.STOP,
|
|
stop_price=final_stop,
|
|
time_in_force=TimeInForce.GTC,
|
|
metadata={"parent_signal_id": signal.signal_id},
|
|
)
|
|
except ValueError:
|
|
# Non-fatal - main order is still valid
|
|
result.validation.warnings.append(
|
|
"Could not create separate stop loss order"
|
|
)
|
|
|
|
# Create separate take profit order if needed
|
|
if final_tp and order_side == OrderSide.BUY:
|
|
try:
|
|
result.take_profit_order = OrderRequest(
|
|
symbol=signal.symbol,
|
|
side=OrderSide.SELL,
|
|
quantity=final_quantity,
|
|
order_type=OrderType.LIMIT,
|
|
limit_price=final_tp,
|
|
time_in_force=TimeInForce.GTC,
|
|
metadata={"parent_signal_id": signal.signal_id},
|
|
)
|
|
except ValueError:
|
|
result.validation.warnings.append(
|
|
"Could not create separate take profit order"
|
|
)
|
|
|
|
result.success = True
|
|
return result
|
|
|
|
def convert_batch(
|
|
self,
|
|
signals: List[TradingSignal],
|
|
) -> List[ConversionResult]:
|
|
"""Convert multiple signals to orders.
|
|
|
|
Args:
|
|
signals: List of trading signals
|
|
|
|
Returns:
|
|
List of ConversionResult for each signal
|
|
"""
|
|
return [self.convert(signal) for signal in signals]
|
|
|
|
def _get_entry_price(self, signal: TradingSignal) -> Optional[Decimal]:
|
|
"""Get entry price for signal.
|
|
|
|
Args:
|
|
signal: The trading signal
|
|
|
|
Returns:
|
|
Entry price or None if unavailable
|
|
"""
|
|
# Use signal's entry price if available
|
|
if signal.entry_price is not None:
|
|
return signal.entry_price
|
|
|
|
# Fall back to current market price
|
|
if signal.symbol in self.current_prices:
|
|
return self.current_prices[signal.symbol]
|
|
|
|
return None
|
|
|
|
def _calculate_position_size(
|
|
self,
|
|
signal: TradingSignal,
|
|
entry_price: Decimal,
|
|
) -> Decimal:
|
|
"""Calculate position size based on configuration.
|
|
|
|
Args:
|
|
signal: The trading signal
|
|
entry_price: Entry price for the trade
|
|
|
|
Returns:
|
|
Position size in shares/contracts
|
|
"""
|
|
config = self.config.position_sizing
|
|
|
|
# Start with base size from method
|
|
if config.method == PositionSizingMethod.FIXED_QUANTITY:
|
|
base_quantity = config.fixed_quantity
|
|
|
|
elif config.method == PositionSizingMethod.FIXED_VALUE:
|
|
if entry_price > 0:
|
|
base_quantity = config.fixed_value / entry_price
|
|
else:
|
|
base_quantity = Decimal("0")
|
|
|
|
elif config.method == PositionSizingMethod.PERCENT_PORTFOLIO:
|
|
position_value = self.portfolio_value * config.percent_portfolio
|
|
if entry_price > 0:
|
|
base_quantity = position_value / entry_price
|
|
else:
|
|
base_quantity = Decimal("0")
|
|
|
|
elif config.method == PositionSizingMethod.RISK_BASED:
|
|
base_quantity = self._calculate_risk_based_size(
|
|
signal=signal,
|
|
entry_price=entry_price,
|
|
)
|
|
|
|
elif config.method == PositionSizingMethod.VOLATILITY_BASED:
|
|
base_quantity = self._calculate_volatility_based_size(
|
|
signal=signal,
|
|
entry_price=entry_price,
|
|
)
|
|
else:
|
|
base_quantity = config.fixed_quantity
|
|
|
|
# Apply strength multiplier
|
|
if self.config.scale_by_strength:
|
|
multiplier = self.config.strength_multipliers.get(
|
|
signal.strength, Decimal("1.0")
|
|
)
|
|
base_quantity *= multiplier
|
|
|
|
# Apply confidence scaling
|
|
if self.config.scale_by_confidence:
|
|
base_quantity *= signal.confidence
|
|
|
|
# Enforce max position size
|
|
max_value = self.portfolio_value * config.max_position_percent
|
|
if entry_price > 0:
|
|
max_quantity = max_value / entry_price
|
|
base_quantity = min(base_quantity, max_quantity)
|
|
|
|
# Round to lot size
|
|
if config.round_to_lot_size and config.lot_size > 0:
|
|
base_quantity = (
|
|
base_quantity / config.lot_size
|
|
).to_integral_value(rounding=ROUND_DOWN) * config.lot_size
|
|
|
|
# Enforce min/max quantity
|
|
base_quantity = max(base_quantity, config.min_quantity)
|
|
if config.max_quantity is not None:
|
|
base_quantity = min(base_quantity, config.max_quantity)
|
|
|
|
return base_quantity
|
|
|
|
def _calculate_risk_based_size(
|
|
self,
|
|
signal: TradingSignal,
|
|
entry_price: Decimal,
|
|
) -> Decimal:
|
|
"""Calculate position size based on risk per trade.
|
|
|
|
Args:
|
|
signal: The trading signal
|
|
entry_price: Entry price
|
|
|
|
Returns:
|
|
Position size in shares
|
|
"""
|
|
config = self.config.position_sizing
|
|
|
|
# Calculate dollar risk allowed
|
|
risk_dollars = self.portfolio_value * config.max_risk_per_trade
|
|
|
|
# Calculate risk per share (distance to stop)
|
|
if signal.stop_price:
|
|
risk_per_share = abs(entry_price - signal.stop_price)
|
|
else:
|
|
# Use default stop loss percentage
|
|
risk_per_share = entry_price * self.config.stop_loss.percent
|
|
|
|
if risk_per_share > 0:
|
|
return risk_dollars / risk_per_share
|
|
else:
|
|
return Decimal("0")
|
|
|
|
def _calculate_volatility_based_size(
|
|
self,
|
|
signal: TradingSignal,
|
|
entry_price: Decimal,
|
|
) -> Decimal:
|
|
"""Calculate position size based on volatility.
|
|
|
|
Args:
|
|
signal: The trading signal
|
|
entry_price: Entry price
|
|
|
|
Returns:
|
|
Position size in shares
|
|
"""
|
|
config = self.config.position_sizing
|
|
|
|
# Get ATR/volatility for symbol
|
|
volatility = self.volatility_data.get(signal.symbol, entry_price * Decimal("0.02"))
|
|
|
|
# Target volatility contribution (e.g., 1% of portfolio)
|
|
target_vol = self.portfolio_value * config.max_risk_per_trade
|
|
|
|
if volatility > 0:
|
|
return target_vol / volatility
|
|
else:
|
|
# Fall back to percent of portfolio
|
|
position_value = self.portfolio_value * config.percent_portfolio
|
|
if entry_price > 0:
|
|
return position_value / entry_price
|
|
return Decimal("0")
|
|
|
|
def _calculate_stop_loss(
|
|
self,
|
|
signal: TradingSignal,
|
|
entry_price: Decimal,
|
|
) -> Optional[Decimal]:
|
|
"""Calculate stop loss price.
|
|
|
|
Args:
|
|
signal: The trading signal
|
|
entry_price: Entry price
|
|
|
|
Returns:
|
|
Stop loss price or None
|
|
"""
|
|
config = self.config.stop_loss
|
|
|
|
# Use signal's stop price if provided
|
|
if signal.stop_price:
|
|
return signal.stop_price
|
|
|
|
# Calculate based on type
|
|
if config.type == StopLossType.FIXED_PERCENT:
|
|
if signal.signal_type in [SignalType.BUY, SignalType.SCALE_IN]:
|
|
return entry_price * (Decimal("1") - config.percent)
|
|
else:
|
|
return entry_price * (Decimal("1") + config.percent)
|
|
|
|
elif config.type == StopLossType.FIXED_AMOUNT:
|
|
if config.amount:
|
|
if signal.signal_type in [SignalType.BUY, SignalType.SCALE_IN]:
|
|
return entry_price - config.amount
|
|
else:
|
|
return entry_price + config.amount
|
|
|
|
elif config.type == StopLossType.ATR_BASED:
|
|
atr = self.volatility_data.get(
|
|
signal.symbol,
|
|
entry_price * Decimal("0.02")
|
|
)
|
|
atr_distance = atr * config.atr_multiplier
|
|
if signal.signal_type in [SignalType.BUY, SignalType.SCALE_IN]:
|
|
return entry_price - atr_distance
|
|
else:
|
|
return entry_price + atr_distance
|
|
|
|
elif config.type == StopLossType.TRAILING:
|
|
if config.trail_percent:
|
|
return entry_price * (Decimal("1") - config.trail_percent)
|
|
elif config.trail_amount:
|
|
return entry_price - config.trail_amount
|
|
|
|
return None
|
|
|
|
def _calculate_take_profit(
|
|
self,
|
|
signal: TradingSignal,
|
|
entry_price: Decimal,
|
|
stop_price: Optional[Decimal],
|
|
) -> Optional[Decimal]:
|
|
"""Calculate take profit price.
|
|
|
|
Args:
|
|
signal: The trading signal
|
|
entry_price: Entry price
|
|
stop_price: Stop loss price (for R:R calculation)
|
|
|
|
Returns:
|
|
Take profit price or None
|
|
"""
|
|
config = self.config.take_profit
|
|
|
|
# Use signal's target price if provided
|
|
if signal.target_price:
|
|
return signal.target_price
|
|
|
|
# Calculate based on type
|
|
if config.type == TakeProfitType.FIXED_PERCENT:
|
|
if signal.signal_type in [SignalType.BUY, SignalType.SCALE_IN]:
|
|
return entry_price * (Decimal("1") + config.percent)
|
|
else:
|
|
return entry_price * (Decimal("1") - config.percent)
|
|
|
|
elif config.type == TakeProfitType.FIXED_AMOUNT:
|
|
if config.amount:
|
|
if signal.signal_type in [SignalType.BUY, SignalType.SCALE_IN]:
|
|
return entry_price + config.amount
|
|
else:
|
|
return entry_price - config.amount
|
|
|
|
elif config.type == TakeProfitType.RISK_REWARD:
|
|
if stop_price:
|
|
risk = abs(entry_price - stop_price)
|
|
reward = risk * config.risk_reward_ratio
|
|
if signal.signal_type in [SignalType.BUY, SignalType.SCALE_IN]:
|
|
return entry_price + reward
|
|
else:
|
|
return entry_price - reward
|
|
|
|
return None
|
|
|
|
def _validate_order(
|
|
self,
|
|
signal: TradingSignal,
|
|
quantity: Decimal,
|
|
entry_price: Decimal,
|
|
stop_price: Optional[Decimal],
|
|
take_profit_price: Optional[Decimal],
|
|
) -> OrderValidationResult:
|
|
"""Validate the generated order.
|
|
|
|
Args:
|
|
signal: Original signal
|
|
quantity: Calculated quantity
|
|
entry_price: Entry price
|
|
stop_price: Stop loss price
|
|
take_profit_price: Take profit price
|
|
|
|
Returns:
|
|
OrderValidationResult
|
|
"""
|
|
result = OrderValidationResult()
|
|
|
|
# Validate symbol
|
|
if not signal.symbol:
|
|
result.is_valid = False
|
|
result.errors.append((
|
|
OrderValidationError.INVALID_SYMBOL,
|
|
"Symbol is required"
|
|
))
|
|
|
|
# Validate quantity
|
|
if quantity <= 0:
|
|
result.is_valid = False
|
|
result.errors.append((
|
|
OrderValidationError.INVALID_QUANTITY,
|
|
f"Quantity must be positive, got {quantity}"
|
|
))
|
|
|
|
# Validate entry price
|
|
if entry_price <= 0:
|
|
result.is_valid = False
|
|
result.errors.append((
|
|
OrderValidationError.INVALID_PRICE,
|
|
f"Entry price must be positive, got {entry_price}"
|
|
))
|
|
|
|
# Validate position value vs portfolio
|
|
position_value = quantity * entry_price
|
|
max_position = self.portfolio_value * self.config.position_sizing.max_position_percent
|
|
|
|
if position_value > max_position:
|
|
result.warnings.append(
|
|
f"Position value {position_value} exceeds max {max_position}, "
|
|
f"adjusting quantity"
|
|
)
|
|
result.adjusted_quantity = (max_position / entry_price).to_integral_value(
|
|
rounding=ROUND_DOWN
|
|
)
|
|
|
|
# Validate stop loss
|
|
if stop_price is not None:
|
|
if signal.signal_type in [SignalType.BUY, SignalType.SCALE_IN]:
|
|
if stop_price >= entry_price:
|
|
result.is_valid = False
|
|
result.errors.append((
|
|
OrderValidationError.INVALID_STOP_LOSS,
|
|
f"Stop loss {stop_price} must be below entry {entry_price} for BUY"
|
|
))
|
|
else:
|
|
if stop_price <= entry_price:
|
|
result.is_valid = False
|
|
result.errors.append((
|
|
OrderValidationError.INVALID_STOP_LOSS,
|
|
f"Stop loss {stop_price} must be above entry {entry_price} for SELL"
|
|
))
|
|
|
|
# Validate take profit
|
|
if take_profit_price is not None:
|
|
if signal.signal_type in [SignalType.BUY, SignalType.SCALE_IN]:
|
|
if take_profit_price <= entry_price:
|
|
result.is_valid = False
|
|
result.errors.append((
|
|
OrderValidationError.INVALID_TAKE_PROFIT,
|
|
f"Take profit {take_profit_price} must be above entry {entry_price} for BUY"
|
|
))
|
|
else:
|
|
if take_profit_price >= entry_price:
|
|
result.is_valid = False
|
|
result.errors.append((
|
|
OrderValidationError.INVALID_TAKE_PROFIT,
|
|
f"Take profit {take_profit_price} must be below entry {entry_price} for SELL"
|
|
))
|
|
|
|
# Check risk limit
|
|
if stop_price is not None and result.is_valid:
|
|
risk_per_share = abs(entry_price - stop_price)
|
|
final_qty = result.adjusted_quantity or quantity
|
|
total_risk = risk_per_share * final_qty
|
|
max_risk = self.portfolio_value * self.config.position_sizing.max_risk_per_trade
|
|
|
|
if total_risk > max_risk * Decimal("2"): # Allow some buffer
|
|
result.warnings.append(
|
|
f"Total risk {total_risk} high relative to max {max_risk}"
|
|
)
|
|
|
|
return result
|
|
|
|
def _signal_to_side(self, signal_type: SignalType) -> OrderSide:
|
|
"""Convert signal type to order side.
|
|
|
|
Args:
|
|
signal_type: The signal type
|
|
|
|
Returns:
|
|
OrderSide (BUY or SELL)
|
|
"""
|
|
if signal_type in [SignalType.BUY, SignalType.SCALE_IN]:
|
|
return OrderSide.BUY
|
|
else:
|
|
return OrderSide.SELL
|
|
|
|
def _determine_order_type(
|
|
self,
|
|
signal: TradingSignal,
|
|
entry_price: Decimal,
|
|
) -> Tuple[OrderType, Optional[Decimal]]:
|
|
"""Determine order type and limit price.
|
|
|
|
Args:
|
|
signal: The trading signal
|
|
entry_price: Entry price
|
|
|
|
Returns:
|
|
Tuple of (OrderType, limit_price or None)
|
|
"""
|
|
if self.config.use_limit_orders:
|
|
# Calculate limit price with offset
|
|
offset = entry_price * self.config.limit_order_offset
|
|
if signal.signal_type in [SignalType.BUY, SignalType.SCALE_IN]:
|
|
limit_price = entry_price + offset
|
|
else:
|
|
limit_price = entry_price - offset
|
|
return OrderType.LIMIT, limit_price
|
|
else:
|
|
return self.config.default_order_type, None
|
|
|
|
def update_portfolio_value(self, value: Decimal):
|
|
"""Update the portfolio value.
|
|
|
|
Args:
|
|
value: New portfolio value
|
|
"""
|
|
self.portfolio_value = value
|
|
|
|
def update_price(self, symbol: str, price: Decimal):
|
|
"""Update the current price for a symbol.
|
|
|
|
Args:
|
|
symbol: Trading symbol
|
|
price: Current market price
|
|
"""
|
|
self.current_prices[symbol] = price
|
|
|
|
def update_volatility(self, symbol: str, volatility: Decimal):
|
|
"""Update the volatility/ATR for a symbol.
|
|
|
|
Args:
|
|
symbol: Trading symbol
|
|
volatility: ATR or volatility value
|
|
"""
|
|
self.volatility_data[symbol] = volatility
|