From c423c6bdeb35a10c66c01eff3a1ad58d4bb2fea8 Mon Sep 17 00:00:00 2001 From: Andrew Kaszubski Date: Fri, 26 Dec 2025 22:20:18 +1100 Subject: [PATCH] feat(strategy): add Signal to Order converter with position sizing - Issue #36 (56 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements comprehensive signal to order conversion framework: - TradingSignal dataclass for BUY/SELL/HOLD signals - SignalToOrderConverter main class - Multiple position sizing methods (fixed, percent, risk-based, volatility) - Configurable stop loss (fixed percent, ATR-based, trailing) - Configurable take profit (fixed percent, R:R ratio) Features: - Signal strength multipliers (strong/moderate/weak) - Confidence scaling for position sizes - Order validation with detailed error reporting - Lot size rounding - Max position size limits - Automatic stop loss and take profit orders - Support for limit orders with configurable offset - Batch signal conversion - Integration with OrderRequest from broker_base 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- tests/unit/strategy/test_signal_to_order.py | 741 ++++++++++++++++ tradingagents/strategy/__init__.py | 96 ++ tradingagents/strategy/signal_to_order.py | 917 ++++++++++++++++++++ 3 files changed, 1754 insertions(+) create mode 100644 tests/unit/strategy/test_signal_to_order.py create mode 100644 tradingagents/strategy/__init__.py create mode 100644 tradingagents/strategy/signal_to_order.py diff --git a/tests/unit/strategy/test_signal_to_order.py b/tests/unit/strategy/test_signal_to_order.py new file mode 100644 index 00000000..9ca39808 --- /dev/null +++ b/tests/unit/strategy/test_signal_to_order.py @@ -0,0 +1,741 @@ +"""Tests for Signal to Order Converter. + +Issue #36: [STRAT-35] Signal to order converter +""" + +from datetime import datetime +from decimal import Decimal +import pytest + +from tradingagents.strategy.signal_to_order import ( + # Enums + SignalType, + SignalStrength, + PositionSizingMethod, + StopLossType, + TakeProfitType, + OrderValidationError, + # Data Classes + TradingSignal, + PositionSizingConfig, + StopLossConfig, + TakeProfitConfig, + ConversionConfig, + OrderValidationResult, + ConversionResult, + # Main Class + SignalToOrderConverter, +) +from tradingagents.execution.broker_base import OrderSide, OrderType + + +# ============================================================================ +# Enum Tests +# ============================================================================ + +class TestSignalType: + """Tests for SignalType enum.""" + + def test_all_signal_types_defined(self): + """Verify all signal types exist.""" + assert SignalType.BUY + assert SignalType.SELL + assert SignalType.HOLD + assert SignalType.CLOSE + assert SignalType.SCALE_IN + assert SignalType.SCALE_OUT + + def test_signal_values(self): + """Verify signal type values.""" + assert SignalType.BUY.value == "buy" + assert SignalType.SELL.value == "sell" + + +class TestSignalStrength: + """Tests for SignalStrength enum.""" + + def test_all_strengths_defined(self): + """Verify all strengths exist.""" + assert SignalStrength.STRONG + assert SignalStrength.MODERATE + assert SignalStrength.WEAK + + +class TestPositionSizingMethod: + """Tests for PositionSizingMethod enum.""" + + def test_all_methods_defined(self): + """Verify all sizing methods exist.""" + assert PositionSizingMethod.FIXED_QUANTITY + assert PositionSizingMethod.FIXED_VALUE + assert PositionSizingMethod.PERCENT_PORTFOLIO + assert PositionSizingMethod.RISK_BASED + assert PositionSizingMethod.VOLATILITY_BASED + + +class TestStopLossType: + """Tests for StopLossType enum.""" + + def test_all_stop_types_defined(self): + """Verify all stop loss types exist.""" + assert StopLossType.FIXED_PERCENT + assert StopLossType.FIXED_AMOUNT + assert StopLossType.ATR_BASED + assert StopLossType.TRAILING + + +class TestTakeProfitType: + """Tests for TakeProfitType enum.""" + + def test_all_tp_types_defined(self): + """Verify all take profit types exist.""" + assert TakeProfitType.FIXED_PERCENT + assert TakeProfitType.FIXED_AMOUNT + assert TakeProfitType.RISK_REWARD + + +# ============================================================================ +# Data Class Tests +# ============================================================================ + +class TestTradingSignal: + """Tests for TradingSignal dataclass.""" + + def test_default_creation(self): + """Test creating signal with defaults.""" + signal = TradingSignal() + assert signal.signal_id is not None + assert signal.timestamp is not None + assert signal.signal_type == SignalType.HOLD + assert signal.strength == SignalStrength.MODERATE + assert signal.confidence == Decimal("0.5") + + def test_with_all_fields(self): + """Test creating signal with all fields.""" + signal = TradingSignal( + symbol="AAPL", + signal_type=SignalType.BUY, + strength=SignalStrength.STRONG, + entry_price=Decimal("150.00"), + target_price=Decimal("165.00"), + stop_price=Decimal("145.00"), + confidence=Decimal("0.85"), + reason="Bullish breakout", + ) + assert signal.symbol == "AAPL" + assert signal.signal_type == SignalType.BUY + assert signal.entry_price == Decimal("150.00") + + +class TestPositionSizingConfig: + """Tests for PositionSizingConfig dataclass.""" + + def test_default_creation(self): + """Test creating config with defaults.""" + config = PositionSizingConfig() + assert config.method == PositionSizingMethod.PERCENT_PORTFOLIO + assert config.percent_portfolio == Decimal("0.05") + assert config.max_risk_per_trade == Decimal("0.01") + + def test_custom_config(self): + """Test creating custom config.""" + config = PositionSizingConfig( + method=PositionSizingMethod.RISK_BASED, + max_risk_per_trade=Decimal("0.02"), + ) + assert config.method == PositionSizingMethod.RISK_BASED + + +class TestStopLossConfig: + """Tests for StopLossConfig dataclass.""" + + def test_default_creation(self): + """Test creating config with defaults.""" + config = StopLossConfig() + assert config.type == StopLossType.FIXED_PERCENT + assert config.percent == Decimal("0.02") + assert config.enabled is True + + +class TestTakeProfitConfig: + """Tests for TakeProfitConfig dataclass.""" + + def test_default_creation(self): + """Test creating config with defaults.""" + config = TakeProfitConfig() + assert config.type == TakeProfitType.RISK_REWARD + assert config.risk_reward_ratio == Decimal("3.0") + + +class TestConversionConfig: + """Tests for ConversionConfig dataclass.""" + + def test_default_creation(self): + """Test creating config with defaults.""" + config = ConversionConfig() + assert config.default_order_type == OrderType.MARKET + assert config.scale_by_strength is True + + +class TestOrderValidationResult: + """Tests for OrderValidationResult dataclass.""" + + def test_default_is_valid(self): + """Test default is valid.""" + result = OrderValidationResult() + assert result.is_valid is True + assert result.errors == [] + + +class TestConversionResult: + """Tests for ConversionResult dataclass.""" + + def test_default_creation(self): + """Test creating result with defaults.""" + result = ConversionResult() + assert result.success is True + assert result.order_request is None + + +# ============================================================================ +# SignalToOrderConverter Tests +# ============================================================================ + +class TestSignalToOrderConverter: + """Tests for SignalToOrderConverter class.""" + + @pytest.fixture + def converter(self): + """Create default converter.""" + return SignalToOrderConverter( + portfolio_value=Decimal("100000"), + current_prices={"AAPL": Decimal("150.00")}, + ) + + @pytest.fixture + def buy_signal(self): + """Create a buy signal.""" + return TradingSignal( + symbol="AAPL", + signal_type=SignalType.BUY, + strength=SignalStrength.STRONG, + confidence=Decimal("0.8"), + ) + + @pytest.fixture + def sell_signal(self): + """Create a sell signal.""" + return TradingSignal( + symbol="AAPL", + signal_type=SignalType.SELL, + strength=SignalStrength.MODERATE, + confidence=Decimal("0.7"), + ) + + def test_initialization(self, converter): + """Test converter initialization.""" + assert converter.portfolio_value == Decimal("100000") + assert "AAPL" in converter.current_prices + assert converter.config is not None + + def test_custom_config(self): + """Test converter with custom config.""" + config = ConversionConfig( + use_limit_orders=True, + ) + converter = SignalToOrderConverter(config=config) + assert converter.config.use_limit_orders is True + + def test_convert_buy_signal(self, converter, buy_signal): + """Test converting a buy signal.""" + result = converter.convert(buy_signal) + assert result.success is True + assert result.order_request is not None + assert result.order_request.side == OrderSide.BUY + assert result.order_request.symbol == "AAPL" + + def test_convert_sell_signal(self, converter, sell_signal): + """Test converting a sell signal.""" + result = converter.convert(sell_signal) + assert result.success is True + assert result.order_request.side == OrderSide.SELL + + def test_hold_signal_no_order(self, converter): + """Test that HOLD signals don't generate orders.""" + signal = TradingSignal( + symbol="AAPL", + signal_type=SignalType.HOLD, + ) + result = converter.convert(signal) + assert result.success is False + assert "HOLD" in result.error_message + + def test_position_sizing_percent_portfolio(self, converter, buy_signal): + """Test position sizing with percent of portfolio.""" + result = converter.convert(buy_signal) + # 5% of 100k = 5000, divided by 150 = ~33 shares + # With strong signal multiplier of 1.0 + assert result.calculated_quantity > 0 + assert result.calculated_quantity <= Decimal("100") # Reasonable size + + def test_position_sizing_fixed_quantity(self, buy_signal): + """Test fixed quantity position sizing.""" + config = ConversionConfig( + position_sizing=PositionSizingConfig( + method=PositionSizingMethod.FIXED_QUANTITY, + fixed_quantity=Decimal("50"), + ), + ) + converter = SignalToOrderConverter( + config=config, + current_prices={"AAPL": Decimal("150.00")}, + ) + result = converter.convert(buy_signal) + # Should use fixed quantity (possibly scaled by strength) + assert result.calculated_quantity > 0 + + def test_position_sizing_fixed_value(self, buy_signal): + """Test fixed value position sizing.""" + config = ConversionConfig( + position_sizing=PositionSizingConfig( + method=PositionSizingMethod.FIXED_VALUE, + fixed_value=Decimal("7500"), + ), + scale_by_strength=False, + ) + converter = SignalToOrderConverter( + config=config, + current_prices={"AAPL": Decimal("150.00")}, + ) + result = converter.convert(buy_signal) + # 7500 / 150 = 50 shares + assert result.calculated_quantity == Decimal("50") + + def test_position_sizing_risk_based(self, buy_signal): + """Test risk-based position sizing.""" + config = ConversionConfig( + position_sizing=PositionSizingConfig( + method=PositionSizingMethod.RISK_BASED, + max_risk_per_trade=Decimal("0.01"), # 1% = $1000 + ), + scale_by_strength=False, + ) + converter = SignalToOrderConverter( + config=config, + portfolio_value=Decimal("100000"), + current_prices={"AAPL": Decimal("150.00")}, + ) + result = converter.convert(buy_signal) + assert result.calculated_quantity > 0 + + def test_strength_multiplier_strong(self, converter, buy_signal): + """Test that strong signals get full position size.""" + buy_signal.strength = SignalStrength.STRONG + result = converter.convert(buy_signal) + strong_qty = result.calculated_quantity + + buy_signal.strength = SignalStrength.WEAK + result2 = converter.convert(buy_signal) + weak_qty = result2.calculated_quantity + + # Strong should be >= weak (multipliers 1.0 vs 0.5) + assert strong_qty >= weak_qty + + def test_stop_loss_calculated(self, converter, buy_signal): + """Test stop loss is calculated.""" + result = converter.convert(buy_signal) + assert result.calculated_stop_price is not None + # For buy, stop should be below entry + assert result.calculated_stop_price < Decimal("150.00") + + def test_stop_loss_fixed_percent(self, buy_signal): + """Test fixed percent stop loss.""" + config = ConversionConfig( + stop_loss=StopLossConfig( + type=StopLossType.FIXED_PERCENT, + percent=Decimal("0.05"), # 5% + ), + ) + converter = SignalToOrderConverter( + config=config, + current_prices={"AAPL": Decimal("150.00")}, + ) + result = converter.convert(buy_signal) + # 150 * (1 - 0.05) = 142.50 + assert result.calculated_stop_price == Decimal("142.50") + + def test_stop_loss_atr_based(self, buy_signal): + """Test ATR-based stop loss.""" + config = ConversionConfig( + stop_loss=StopLossConfig( + type=StopLossType.ATR_BASED, + atr_multiplier=Decimal("2.0"), + ), + ) + converter = SignalToOrderConverter( + config=config, + current_prices={"AAPL": Decimal("150.00")}, + volatility_data={"AAPL": Decimal("3.00")}, # $3 ATR + ) + result = converter.convert(buy_signal) + # 150 - (3 * 2) = 144 + assert result.calculated_stop_price == Decimal("144.00") + + def test_take_profit_calculated(self, converter, buy_signal): + """Test take profit is calculated.""" + result = converter.convert(buy_signal) + assert result.calculated_take_profit is not None + # For buy, take profit should be above entry + assert result.calculated_take_profit > Decimal("150.00") + + def test_take_profit_risk_reward(self, buy_signal): + """Test risk:reward take profit.""" + config = ConversionConfig( + stop_loss=StopLossConfig( + type=StopLossType.FIXED_PERCENT, + percent=Decimal("0.02"), # 2% stop = $3 risk + ), + take_profit=TakeProfitConfig( + type=TakeProfitType.RISK_REWARD, + risk_reward_ratio=Decimal("3.0"), # 3:1 + ), + ) + converter = SignalToOrderConverter( + config=config, + current_prices={"AAPL": Decimal("150.00")}, + ) + result = converter.convert(buy_signal) + # Stop at 147, risk = 3, reward = 9, target = 159 + assert result.calculated_take_profit == Decimal("159.00") + + def test_validation_invalid_symbol(self, converter): + """Test validation rejects empty symbol.""" + signal = TradingSignal( + symbol="", + signal_type=SignalType.BUY, + ) + result = converter.convert(signal) + assert result.success is False + # Empty symbol with no price will fail on entry price first + # Or validation will catch it + assert ( + "entry price" in result.error_message.lower() or + any(e == OrderValidationError.INVALID_SYMBOL for e, _ in result.validation.errors) + ) + + def test_validation_insufficient_price(self, converter): + """Test validation when price not available.""" + signal = TradingSignal( + symbol="UNKNOWN", + signal_type=SignalType.BUY, + ) + result = converter.convert(signal) + assert result.success is False + + def test_use_signal_entry_price(self, converter): + """Test using signal's entry price.""" + signal = TradingSignal( + symbol="AAPL", + signal_type=SignalType.BUY, + entry_price=Decimal("152.00"), # Different from current + ) + result = converter.convert(signal) + # Stop should be based on 152, not 150 + # With 2% stop: 152 * 0.98 = 148.96 + assert result.calculated_stop_price == Decimal("148.96") + + def test_use_signal_stop_price(self, converter): + """Test using signal's stop price.""" + signal = TradingSignal( + symbol="AAPL", + signal_type=SignalType.BUY, + stop_price=Decimal("140.00"), + ) + result = converter.convert(signal) + assert result.calculated_stop_price == Decimal("140.00") + + def test_use_signal_target_price(self, converter): + """Test using signal's target price.""" + signal = TradingSignal( + symbol="AAPL", + signal_type=SignalType.BUY, + target_price=Decimal("170.00"), + ) + result = converter.convert(signal) + assert result.calculated_take_profit == Decimal("170.00") + + def test_min_confidence(self, buy_signal): + """Test minimum confidence threshold.""" + config = ConversionConfig( + min_confidence=Decimal("0.9"), + ) + converter = SignalToOrderConverter( + config=config, + current_prices={"AAPL": Decimal("150.00")}, + ) + buy_signal.confidence = Decimal("0.8") + result = converter.convert(buy_signal) + assert result.success is False + assert "confidence" in result.error_message.lower() + + def test_order_metadata(self, converter, buy_signal): + """Test that order includes signal metadata.""" + result = converter.convert(buy_signal) + assert result.order_request.metadata["signal_id"] == buy_signal.signal_id + assert result.order_request.metadata["signal_strength"] == "strong" + + def test_stop_loss_order_created(self, converter, buy_signal): + """Test separate stop loss order is created.""" + result = converter.convert(buy_signal) + assert result.stop_loss_order is not None + assert result.stop_loss_order.side == OrderSide.SELL + assert result.stop_loss_order.order_type == OrderType.STOP + + def test_take_profit_order_created(self, converter, buy_signal): + """Test separate take profit order is created.""" + result = converter.convert(buy_signal) + assert result.take_profit_order is not None + assert result.take_profit_order.side == OrderSide.SELL + assert result.take_profit_order.order_type == OrderType.LIMIT + + def test_limit_orders(self, buy_signal): + """Test using limit orders instead of market.""" + config = ConversionConfig( + use_limit_orders=True, + limit_order_offset=Decimal("0.01"), # 1% + ) + converter = SignalToOrderConverter( + config=config, + current_prices={"AAPL": Decimal("150.00")}, + ) + result = converter.convert(buy_signal) + assert result.order_request.order_type == OrderType.LIMIT + # Buy limit should be above current: 150 * 1.01 = 151.50 + assert result.order_request.limit_price == Decimal("151.50") + + def test_convert_batch(self, converter): + """Test batch conversion.""" + signals = [ + TradingSignal(symbol="AAPL", signal_type=SignalType.BUY), + TradingSignal(symbol="AAPL", signal_type=SignalType.SELL), + TradingSignal(symbol="AAPL", signal_type=SignalType.HOLD), + ] + results = converter.convert_batch(signals) + assert len(results) == 3 + assert results[0].success is True + assert results[1].success is True + assert results[2].success is False # HOLD + + def test_update_portfolio_value(self, converter): + """Test updating portfolio value.""" + converter.update_portfolio_value(Decimal("200000")) + assert converter.portfolio_value == Decimal("200000") + + def test_update_price(self, converter): + """Test updating price.""" + converter.update_price("MSFT", Decimal("300.00")) + assert converter.current_prices["MSFT"] == Decimal("300.00") + + def test_update_volatility(self, converter): + """Test updating volatility.""" + converter.update_volatility("AAPL", Decimal("5.00")) + assert converter.volatility_data["AAPL"] == Decimal("5.00") + + def test_scale_in_signal(self, converter): + """Test SCALE_IN signal.""" + signal = TradingSignal( + symbol="AAPL", + signal_type=SignalType.SCALE_IN, + ) + result = converter.convert(signal) + assert result.success is True + assert result.order_request.side == OrderSide.BUY + + def test_scale_out_signal(self, converter): + """Test SCALE_OUT signal.""" + signal = TradingSignal( + symbol="AAPL", + signal_type=SignalType.SCALE_OUT, + ) + result = converter.convert(signal) + assert result.success is True + assert result.order_request.side == OrderSide.SELL + + def test_close_signal(self, converter): + """Test CLOSE signal.""" + signal = TradingSignal( + symbol="AAPL", + signal_type=SignalType.CLOSE, + ) + result = converter.convert(signal) + assert result.success is True + assert result.order_request.side == OrderSide.SELL + + def test_sell_stop_above_entry(self, converter, sell_signal): + """Test sell signal stop is above entry.""" + result = converter.convert(sell_signal) + # For short, stop should be above entry + assert result.calculated_stop_price > Decimal("150.00") + + def test_sell_take_profit_below_entry(self, converter, sell_signal): + """Test sell signal take profit is below entry.""" + result = converter.convert(sell_signal) + # For short, take profit should be below entry + assert result.calculated_take_profit < Decimal("150.00") + + def test_disabled_stop_loss(self, buy_signal): + """Test with stop loss disabled.""" + config = ConversionConfig( + stop_loss=StopLossConfig(enabled=False), + ) + converter = SignalToOrderConverter( + config=config, + current_prices={"AAPL": Decimal("150.00")}, + ) + result = converter.convert(buy_signal) + assert result.calculated_stop_price is None + assert result.stop_loss_order is None + + def test_disabled_take_profit(self, buy_signal): + """Test with take profit disabled.""" + config = ConversionConfig( + take_profit=TakeProfitConfig(enabled=False), + ) + converter = SignalToOrderConverter( + config=config, + current_prices={"AAPL": Decimal("150.00")}, + ) + result = converter.convert(buy_signal) + assert result.calculated_take_profit is None + assert result.take_profit_order is None + + def test_lot_size_rounding(self, buy_signal): + """Test rounding to lot size.""" + config = ConversionConfig( + position_sizing=PositionSizingConfig( + method=PositionSizingMethod.FIXED_VALUE, + fixed_value=Decimal("7555"), # Would give 50.366... + lot_size=Decimal("10"), + ), + scale_by_strength=False, + ) + converter = SignalToOrderConverter( + config=config, + current_prices={"AAPL": Decimal("150.00")}, + ) + result = converter.convert(buy_signal) + # Should round down to 50 + assert result.calculated_quantity == Decimal("50") + + def test_max_position_limit(self, buy_signal): + """Test max position size limit.""" + config = ConversionConfig( + position_sizing=PositionSizingConfig( + method=PositionSizingMethod.FIXED_VALUE, + fixed_value=Decimal("50000"), # 50% of portfolio + max_position_percent=Decimal("0.10"), # But max is 10% + ), + scale_by_strength=False, + ) + converter = SignalToOrderConverter( + config=config, + portfolio_value=Decimal("100000"), + current_prices={"AAPL": Decimal("150.00")}, + ) + result = converter.convert(buy_signal) + # Max is 10k / 150 = 66 shares + assert result.calculated_quantity <= Decimal("67") + + +# ============================================================================ +# Integration Tests +# ============================================================================ + +class TestSignalToOrderIntegration: + """Integration tests for signal to order conversion.""" + + def test_full_workflow(self): + """Test complete conversion workflow.""" + # Setup converter with realistic config + config = ConversionConfig( + position_sizing=PositionSizingConfig( + method=PositionSizingMethod.RISK_BASED, + max_risk_per_trade=Decimal("0.01"), # 1% + ), + stop_loss=StopLossConfig( + type=StopLossType.FIXED_PERCENT, + percent=Decimal("0.02"), # 2% + ), + take_profit=TakeProfitConfig( + type=TakeProfitType.RISK_REWARD, + risk_reward_ratio=Decimal("3.0"), + ), + ) + + converter = SignalToOrderConverter( + config=config, + portfolio_value=Decimal("100000"), + current_prices={"AAPL": Decimal("150.00")}, + ) + + # Generate signal + signal = TradingSignal( + symbol="AAPL", + signal_type=SignalType.BUY, + strength=SignalStrength.STRONG, + confidence=Decimal("0.85"), + reason="Bullish breakout with volume", + ) + + # Convert + result = converter.convert(signal) + + # Verify complete result + assert result.success is True + assert result.order_request is not None + assert result.order_request.symbol == "AAPL" + assert result.order_request.side == OrderSide.BUY + assert result.calculated_quantity > 0 + assert result.calculated_stop_price == Decimal("147.00") + assert result.calculated_take_profit == Decimal("159.00") + + def test_module_imports(self): + """Test that all classes are exported from module.""" + from tradingagents.strategy import ( + SignalType, + SignalStrength, + PositionSizingMethod, + StopLossType, + TakeProfitType, + TradingSignal, + ConversionConfig, + SignalToOrderConverter, + ) + + # All imports successful + assert SignalType.BUY is not None + assert SignalToOrderConverter is not None + + def test_multiple_symbols(self): + """Test converting signals for multiple symbols.""" + converter = SignalToOrderConverter( + portfolio_value=Decimal("100000"), + current_prices={ + "AAPL": Decimal("150.00"), + "MSFT": Decimal("300.00"), + "GOOG": Decimal("100.00"), + }, + ) + + signals = [ + TradingSignal(symbol="AAPL", signal_type=SignalType.BUY), + TradingSignal(symbol="MSFT", signal_type=SignalType.SELL), + TradingSignal(symbol="GOOG", signal_type=SignalType.BUY), + ] + + results = converter.convert_batch(signals) + + assert all(r.success for r in results) + assert results[0].order_request.symbol == "AAPL" + assert results[1].order_request.symbol == "MSFT" + assert results[2].order_request.symbol == "GOOG" diff --git a/tradingagents/strategy/__init__.py b/tradingagents/strategy/__init__.py new file mode 100644 index 00000000..284c2ecf --- /dev/null +++ b/tradingagents/strategy/__init__.py @@ -0,0 +1,96 @@ +"""Strategy module for trading strategy execution. + +This module provides strategy orchestration including: +- Signal to order conversion +- End-to-end strategy execution +- Position management + +Issue #36: [STRAT-35] Signal to order converter +Issue #37: [STRAT-36] Strategy executor - end-to-end orchestration + +Submodules: + signal_to_order: Convert signals to executable orders + +Classes: + Enums: + - SignalType: Type of trading signal (buy, sell, hold) + - SignalStrength: Strength of signal (strong, moderate, weak) + - PositionSizingMethod: Position sizing method + - StopLossType: Type of stop loss + - TakeProfitType: Type of take profit + - OrderValidationError: Order validation error types + + Data Classes: + - TradingSignal: A trading signal from strategy + - PositionSizingConfig: Position sizing configuration + - StopLossConfig: Stop loss configuration + - TakeProfitConfig: Take profit configuration + - ConversionConfig: Signal to order conversion config + - OrderValidationResult: Result of order validation + - ConversionResult: Result of signal to order conversion + + Main Classes: + - SignalToOrderConverter: Converts signals to orders + +Example: + >>> from tradingagents.strategy import ( + ... SignalToOrderConverter, + ... TradingSignal, + ... SignalType, + ... ConversionConfig, + ... ) + >>> from decimal import Decimal + >>> + >>> converter = SignalToOrderConverter( + ... portfolio_value=Decimal("100000"), + ... current_prices={"AAPL": Decimal("150.00")}, + ... ) + >>> + >>> signal = TradingSignal( + ... symbol="AAPL", + ... signal_type=SignalType.BUY, + ... ) + >>> result = converter.convert(signal) + >>> if result.success: + ... print(f"Order: {result.order_request}") +""" + +from .signal_to_order import ( + # Enums + SignalType, + SignalStrength, + PositionSizingMethod, + StopLossType, + TakeProfitType, + OrderValidationError, + # Data Classes + TradingSignal, + PositionSizingConfig, + StopLossConfig, + TakeProfitConfig, + ConversionConfig, + OrderValidationResult, + ConversionResult, + # Main Class + SignalToOrderConverter, +) + +__all__ = [ + # Enums + "SignalType", + "SignalStrength", + "PositionSizingMethod", + "StopLossType", + "TakeProfitType", + "OrderValidationError", + # Data Classes + "TradingSignal", + "PositionSizingConfig", + "StopLossConfig", + "TakeProfitConfig", + "ConversionConfig", + "OrderValidationResult", + "ConversionResult", + # Main Class + "SignalToOrderConverter", +] diff --git a/tradingagents/strategy/signal_to_order.py b/tradingagents/strategy/signal_to_order.py new file mode 100644 index 00000000..272d9b54 --- /dev/null +++ b/tradingagents/strategy/signal_to_order.py @@ -0,0 +1,917 @@ +"""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