Add Phase 2 foundation: regime classifier, strategy framework, WebSocket streamer
Phase 1 completion: - Add DataStreamer for real-time Binance Futures WebSocket data (klines, mark price) - Add DataValidator for candle validation and gap detection - Add timeframes module with interval mappings Phase 2 foundation: - Add RegimeClassifier with ADX/ATR/Bollinger Band analysis - Add Regime enum (TRENDING_UP/DOWN, RANGING, HIGH_VOLATILITY, UNCERTAIN) - Add Strategy ABC defining generate_signal, get_stop_loss, parameters, suitable_regimes - Add Signal dataclass and SignalType enum for strategy outputs Testing: - Add comprehensive test suites for all new modules - 159 tests passing, 24 skipped (async WebSocket timing) - 82% code coverage Dependencies: - Add pandas-stubs to dev dependencies for mypy compatibility
This commit is contained in:
419
tests/test_signals.py
Normal file
419
tests/test_signals.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""Unit tests for trading signals (Signal, SignalType)."""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from tradefinder.strategies.signals import Signal, SignalType
|
||||
|
||||
|
||||
class TestSignalType:
|
||||
"""Tests for SignalType enum."""
|
||||
|
||||
def test_signal_type_values(self) -> None:
|
||||
"""SignalType has correct string values."""
|
||||
assert SignalType.ENTRY_LONG.value == "entry_long"
|
||||
assert SignalType.ENTRY_SHORT.value == "entry_short"
|
||||
assert SignalType.EXIT_LONG.value == "exit_long"
|
||||
assert SignalType.EXIT_SHORT.value == "exit_short"
|
||||
|
||||
|
||||
class TestSignal:
|
||||
"""Tests for Signal dataclass."""
|
||||
|
||||
def test_signal_creation_valid(self) -> None:
|
||||
"""Valid signal can be created."""
|
||||
signal = Signal(
|
||||
signal_type=SignalType.ENTRY_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("49000.00"),
|
||||
take_profit=Decimal("52000.00"),
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert signal.signal_type == SignalType.ENTRY_LONG
|
||||
assert signal.symbol == "BTCUSDT"
|
||||
assert signal.price == Decimal("50000.00")
|
||||
assert signal.stop_loss == Decimal("49000.00")
|
||||
assert signal.take_profit == Decimal("52000.00")
|
||||
assert signal.confidence == 0.8
|
||||
assert signal.strategy_name == "test_strategy"
|
||||
|
||||
def test_signal_creation_without_take_profit(self) -> None:
|
||||
"""Signal can be created without take profit."""
|
||||
signal = Signal(
|
||||
signal_type=SignalType.ENTRY_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("49000.00"),
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert signal.take_profit is None
|
||||
|
||||
def test_signal_validation_confidence_too_low(self) -> None:
|
||||
"""Confidence below 0.0 raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Confidence must be between 0.0 and 1.0"):
|
||||
Signal(
|
||||
signal_type=SignalType.ENTRY_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("49000.00"),
|
||||
take_profit=None,
|
||||
confidence=-0.1, # Invalid
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
|
||||
def test_signal_validation_confidence_too_high(self) -> None:
|
||||
"""Confidence above 1.0 raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Confidence must be between 0.0 and 1.0"):
|
||||
Signal(
|
||||
signal_type=SignalType.ENTRY_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("49000.00"),
|
||||
take_profit=None,
|
||||
confidence=1.1, # Invalid
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
|
||||
def test_signal_validation_zero_price(self) -> None:
|
||||
"""Zero price raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Price must be positive"):
|
||||
Signal(
|
||||
signal_type=SignalType.ENTRY_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("0"), # Invalid
|
||||
stop_loss=Decimal("49000.00"),
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
|
||||
def test_signal_validation_negative_price(self) -> None:
|
||||
"""Negative price raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Price must be positive"):
|
||||
Signal(
|
||||
signal_type=SignalType.ENTRY_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("-50000.00"), # Invalid
|
||||
stop_loss=Decimal("49000.00"),
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
|
||||
def test_signal_validation_zero_stop_loss(self) -> None:
|
||||
"""Zero stop loss raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Stop loss must be positive"):
|
||||
Signal(
|
||||
signal_type=SignalType.ENTRY_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("0"), # Invalid
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
|
||||
def test_signal_validation_negative_stop_loss(self) -> None:
|
||||
"""Negative stop loss raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Stop loss must be positive"):
|
||||
Signal(
|
||||
signal_type=SignalType.ENTRY_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("-49000.00"), # Invalid
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
|
||||
def test_signal_validation_boundary_confidence(self) -> None:
|
||||
"""Boundary confidence values are accepted."""
|
||||
# Test 0.0
|
||||
signal_low = Signal(
|
||||
signal_type=SignalType.ENTRY_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("49000.00"),
|
||||
take_profit=None,
|
||||
confidence=0.0,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert signal_low.confidence == 0.0
|
||||
|
||||
# Test 1.0
|
||||
signal_high = Signal(
|
||||
signal_type=SignalType.ENTRY_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("49000.00"),
|
||||
take_profit=None,
|
||||
confidence=1.0,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert signal_high.confidence == 1.0
|
||||
|
||||
|
||||
class TestSignalProperties:
|
||||
"""Tests for Signal computed properties."""
|
||||
|
||||
def test_is_entry_property(self) -> None:
|
||||
"""is_entry property works correctly."""
|
||||
entry_long = Signal(
|
||||
signal_type=SignalType.ENTRY_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("49000.00"),
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert entry_long.is_entry is True
|
||||
|
||||
entry_short = Signal(
|
||||
signal_type=SignalType.ENTRY_SHORT,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("51000.00"),
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert entry_short.is_entry is True
|
||||
|
||||
exit_long = Signal(
|
||||
signal_type=SignalType.EXIT_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("49000.00"),
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert exit_long.is_entry is False
|
||||
|
||||
def test_is_exit_property(self) -> None:
|
||||
"""is_exit property works correctly."""
|
||||
exit_long = Signal(
|
||||
signal_type=SignalType.EXIT_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("49000.00"),
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert exit_long.is_exit is True
|
||||
|
||||
exit_short = Signal(
|
||||
signal_type=SignalType.EXIT_SHORT,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("51000.00"),
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert exit_short.is_exit is True
|
||||
|
||||
entry_long = Signal(
|
||||
signal_type=SignalType.ENTRY_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("49000.00"),
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert entry_long.is_exit is False
|
||||
|
||||
def test_is_long_property(self) -> None:
|
||||
"""is_long property works correctly."""
|
||||
entry_long = Signal(
|
||||
signal_type=SignalType.ENTRY_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("49000.00"),
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert entry_long.is_long is True
|
||||
|
||||
exit_long = Signal(
|
||||
signal_type=SignalType.EXIT_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("49000.00"),
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert exit_long.is_long is True
|
||||
|
||||
entry_short = Signal(
|
||||
signal_type=SignalType.ENTRY_SHORT,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("51000.00"),
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert entry_short.is_long is False
|
||||
|
||||
def test_is_short_property(self) -> None:
|
||||
"""is_short property works correctly."""
|
||||
entry_short = Signal(
|
||||
signal_type=SignalType.ENTRY_SHORT,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("51000.00"),
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert entry_short.is_short is True
|
||||
|
||||
exit_short = Signal(
|
||||
signal_type=SignalType.EXIT_SHORT,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("51000.00"),
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert exit_short.is_short is True
|
||||
|
||||
entry_long = Signal(
|
||||
signal_type=SignalType.ENTRY_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("49000.00"),
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert entry_long.is_short is False
|
||||
|
||||
|
||||
class TestSignalRiskReward:
|
||||
"""Tests for risk/reward ratio calculation."""
|
||||
|
||||
def test_risk_reward_ratio_with_take_profit(self) -> None:
|
||||
"""Risk/reward ratio is calculated correctly."""
|
||||
signal = Signal(
|
||||
signal_type=SignalType.ENTRY_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("49000.00"), # 1000 loss
|
||||
take_profit=Decimal("52000.00"), # 2000 reward
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert signal.risk_reward_ratio == Decimal("2.0") # 2000/1000
|
||||
|
||||
def test_risk_reward_ratio_short_position(self) -> None:
|
||||
"""Risk/reward ratio works for short positions."""
|
||||
signal = Signal(
|
||||
signal_type=SignalType.ENTRY_SHORT,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("51000.00"), # 1000 loss
|
||||
take_profit=Decimal("48000.00"), # 2000 reward
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert signal.risk_reward_ratio == Decimal("2.0") # 2000/1000
|
||||
|
||||
def test_risk_reward_ratio_without_take_profit(self) -> None:
|
||||
"""None returned when no take profit set."""
|
||||
signal = Signal(
|
||||
signal_type=SignalType.ENTRY_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("49000.00"),
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert signal.risk_reward_ratio is None
|
||||
|
||||
def test_risk_reward_ratio_zero_risk(self) -> None:
|
||||
"""None returned when stop loss equals entry price."""
|
||||
signal = Signal(
|
||||
signal_type=SignalType.ENTRY_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("50000.00"), # Zero risk
|
||||
take_profit=Decimal("52000.00"),
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert signal.risk_reward_ratio is None
|
||||
|
||||
|
||||
class TestSignalMetadata:
|
||||
"""Tests for signal metadata handling."""
|
||||
|
||||
def test_signal_metadata_default(self) -> None:
|
||||
"""Metadata defaults to empty dict."""
|
||||
signal = Signal(
|
||||
signal_type=SignalType.ENTRY_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("49000.00"),
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
)
|
||||
assert signal.metadata == {}
|
||||
|
||||
def test_signal_metadata_custom(self) -> None:
|
||||
"""Custom metadata is stored."""
|
||||
metadata = {"indicator_value": 0.75, "period": 14}
|
||||
signal = Signal(
|
||||
signal_type=SignalType.ENTRY_LONG,
|
||||
symbol="BTCUSDT",
|
||||
price=Decimal("50000.00"),
|
||||
stop_loss=Decimal("49000.00"),
|
||||
take_profit=None,
|
||||
confidence=0.8,
|
||||
timestamp=datetime(2024, 1, 1, 12, 0, 0),
|
||||
strategy_name="test_strategy",
|
||||
metadata=metadata,
|
||||
)
|
||||
assert signal.metadata == metadata
|
||||
Reference in New Issue
Block a user