TradingAgents/tests/unit/strategy/test_signal_to_order.py

742 lines
26 KiB
Python

"""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"