"""Tests for the Signal Generator service. Covers MarketDataManager (SMA, RSI, snapshot) and WeightedEnsemble (signal combination, threshold filtering, strategy source tagging). """ from __future__ import annotations from datetime import datetime, timezone import pytest from services.signal_generator.ensemble import WeightedEnsemble from services.signal_generator.market_data import MarketDataManager from shared.schemas.trading import ( MarketSnapshot, OHLCVBar, SentimentContext, SignalDirection, TradeSignal, ) from shared.strategies.base import BaseStrategy # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_bar(close: float, *, ts_offset: int = 0) -> OHLCVBar: """Create an ``OHLCVBar`` with the given close price.""" return OHLCVBar( timestamp=datetime(2026, 1, 1, 10, ts_offset, tzinfo=timezone.utc), open=close - 0.5, high=close + 1.0, low=close - 1.0, close=close, volume=1000.0, ) class _StubStrategy(BaseStrategy): """Test helper that returns a preconfigured signal.""" def __init__(self, name: str, signal: TradeSignal | None) -> None: self.name = name self._signal = signal async def evaluate(self, ticker, market, sentiment=None): return self._signal def _make_signal( direction: SignalDirection = SignalDirection.LONG, strength: float = 0.8, sources: list[str] | None = None, ) -> TradeSignal: return TradeSignal( ticker="AAPL", direction=direction, strength=strength, strategy_sources=sources or ["test"], timestamp=datetime.now(timezone.utc), ) # --------------------------------------------------------------------------- # MarketDataManager — SMA # --------------------------------------------------------------------------- class TestMarketDataManagerSMA: """Tests for SMA computation inside MarketDataManager.""" def test_sma_basic(self): """SMA-20 should equal the mean of the last 20 close prices.""" mgr = MarketDataManager() closes = list(range(1, 21)) # 1, 2, ..., 20 for i, c in enumerate(closes): mgr.add_bar("AAPL", _make_bar(float(c), ts_offset=i)) snap = mgr.get_snapshot("AAPL") assert snap is not None expected_sma_20 = sum(closes) / 20 assert snap.sma_20 == pytest.approx(expected_sma_20) def test_sma_returns_none_insufficient_data(self): """SMA-20 should be None when fewer than 20 bars exist.""" mgr = MarketDataManager() for i in range(10): mgr.add_bar("AAPL", _make_bar(100.0, ts_offset=i)) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.sma_20 is None def test_sma_50_requires_50_bars(self): """SMA-50 should be None with only 30 bars, present with 50.""" mgr = MarketDataManager() for i in range(30): mgr.add_bar("AAPL", _make_bar(float(i + 1), ts_offset=i)) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.sma_50 is None # Add 20 more for i in range(30, 50): mgr.add_bar("AAPL", _make_bar(float(i + 1), ts_offset=i)) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.sma_50 is not None expected = sum(range(1, 51)) / 50 assert snap.sma_50 == pytest.approx(expected) # --------------------------------------------------------------------------- # MarketDataManager — RSI # --------------------------------------------------------------------------- class TestMarketDataManagerRSI: """Tests for RSI computation inside MarketDataManager.""" def test_rsi_all_gains(self): """RSI should be 100 when all price changes are positive.""" mgr = MarketDataManager() for i in range(20): mgr.add_bar("AAPL", _make_bar(100.0 + i, ts_offset=i)) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.rsi == pytest.approx(100.0) def test_rsi_all_losses(self): """RSI should be 0 when all price changes are negative.""" mgr = MarketDataManager() for i in range(20): mgr.add_bar("AAPL", _make_bar(200.0 - i, ts_offset=i)) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.rsi == pytest.approx(0.0) def test_rsi_mixed(self): """RSI should be between 0 and 100 with mixed gains and losses.""" mgr = MarketDataManager() prices = [44, 44.34, 44.09, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28, 46.00] for i, p in enumerate(prices): mgr.add_bar("AAPL", _make_bar(p, ts_offset=i)) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.rsi is not None assert 0 < snap.rsi < 100 def test_rsi_returns_none_insufficient_data(self): """RSI should be None when fewer than 15 bars exist (need 14+1).""" mgr = MarketDataManager() for i in range(10): mgr.add_bar("AAPL", _make_bar(100.0, ts_offset=i)) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.rsi is None # --------------------------------------------------------------------------- # MarketDataManager — snapshot # --------------------------------------------------------------------------- class TestMarketDataManagerSnapshot: """Tests for get_snapshot behaviour.""" def test_snapshot_returns_none_for_unknown_ticker(self): mgr = MarketDataManager() assert mgr.get_snapshot("UNKNOWN") is None def test_snapshot_uses_latest_bar_for_price(self): mgr = MarketDataManager() mgr.add_bar("AAPL", _make_bar(100.0, ts_offset=0)) mgr.add_bar("AAPL", _make_bar(105.0, ts_offset=1)) snap = mgr.get_snapshot("AAPL") assert snap is not None assert snap.current_price == 105.0 def test_snapshot_contains_bars(self): mgr = MarketDataManager() for i in range(5): mgr.add_bar("AAPL", _make_bar(100.0 + i, ts_offset=i)) snap = mgr.get_snapshot("AAPL") assert snap is not None assert len(snap.bars) == 5 # --------------------------------------------------------------------------- # WeightedEnsemble — combines signals # --------------------------------------------------------------------------- class TestEnsembleCombinesSignals: """Test that the ensemble correctly combines strategy signals.""" @pytest.mark.asyncio async def test_combines_two_long_signals(self): """Two LONG signals should produce a combined LONG signal.""" s1 = _StubStrategy("alpha", _make_signal(SignalDirection.LONG, 0.8)) s2 = _StubStrategy("beta", _make_signal(SignalDirection.LONG, 0.6)) ensemble = WeightedEnsemble([s1, s2], threshold=0.0) market = MarketSnapshot( ticker="AAPL", current_price=150.0, open=149.0, high=151.0, low=148.0, close=150.0, volume=1000, ) weights = {"alpha": 0.5, "beta": 0.5} signal = await ensemble.evaluate("AAPL", market, None, weights) assert signal is not None assert signal.direction == SignalDirection.LONG # Weighted average = (0.8*0.5 + 0.6*0.5) / (0.5+0.5) = 0.7 assert signal.strength == pytest.approx(0.7, abs=0.01) @pytest.mark.asyncio async def test_opposing_signals_net_direction(self): """When strategies disagree, direction follows the stronger weighted side.""" s1 = _StubStrategy("alpha", _make_signal(SignalDirection.LONG, 0.9)) s2 = _StubStrategy("beta", _make_signal(SignalDirection.SHORT, 0.3)) ensemble = WeightedEnsemble([s1, s2], threshold=0.0) market = MarketSnapshot( ticker="AAPL", current_price=150.0, open=149.0, high=151.0, low=148.0, close=150.0, volume=1000, ) weights = {"alpha": 0.5, "beta": 0.5} signal = await ensemble.evaluate("AAPL", market, None, weights) assert signal is not None # Net direction should be LONG since alpha is stronger assert signal.direction == SignalDirection.LONG # --------------------------------------------------------------------------- # WeightedEnsemble — threshold filtering # --------------------------------------------------------------------------- class TestEnsembleThresholdFiltering: """Test that weak combined signals are filtered out by the threshold.""" @pytest.mark.asyncio async def test_below_threshold_returns_none(self): """Combined strength below threshold should yield None.""" # Two opposing signals of similar strength will nearly cancel out s1 = _StubStrategy("alpha", _make_signal(SignalDirection.LONG, 0.5)) s2 = _StubStrategy("beta", _make_signal(SignalDirection.SHORT, 0.45)) ensemble = WeightedEnsemble([s1, s2], threshold=0.5) market = MarketSnapshot( ticker="AAPL", current_price=150.0, open=149.0, high=151.0, low=148.0, close=150.0, volume=1000, ) weights = {"alpha": 0.5, "beta": 0.5} signal = await ensemble.evaluate("AAPL", market, None, weights) assert signal is None @pytest.mark.asyncio async def test_above_threshold_returns_signal(self): """Strong combined signal above threshold should yield a signal.""" s1 = _StubStrategy("alpha", _make_signal(SignalDirection.LONG, 0.9)) ensemble = WeightedEnsemble([s1], threshold=0.3) market = MarketSnapshot( ticker="AAPL", current_price=150.0, open=149.0, high=151.0, low=148.0, close=150.0, volume=1000, ) weights = {"alpha": 1.0} signal = await ensemble.evaluate("AAPL", market, None, weights) assert signal is not None assert signal.strength >= 0.3 # --------------------------------------------------------------------------- # WeightedEnsemble — no signals returns None # --------------------------------------------------------------------------- class TestEnsembleNoSignals: """Test that the ensemble returns None when no strategy fires.""" @pytest.mark.asyncio async def test_all_strategies_return_none(self): s1 = _StubStrategy("alpha", None) s2 = _StubStrategy("beta", None) ensemble = WeightedEnsemble([s1, s2], threshold=0.3) market = MarketSnapshot( ticker="AAPL", current_price=150.0, open=149.0, high=151.0, low=148.0, close=150.0, volume=1000, ) weights = {"alpha": 0.5, "beta": 0.5} signal = await ensemble.evaluate("AAPL", market, None, weights) assert signal is None # --------------------------------------------------------------------------- # WeightedEnsemble — tags strategy sources # --------------------------------------------------------------------------- class TestEnsembleTagsStrategySources: """Verify that the output signal records which strategies contributed.""" @pytest.mark.asyncio async def test_strategy_sources_contains_all_contributors(self): s1 = _StubStrategy("momentum", _make_signal(SignalDirection.LONG, 0.7, ["momentum"])) s2 = _StubStrategy("news_driven", _make_signal(SignalDirection.LONG, 0.6, ["news_driven"])) s3 = _StubStrategy("mean_reversion", None) # does not contribute ensemble = WeightedEnsemble([s1, s2, s3], threshold=0.0) market = MarketSnapshot( ticker="AAPL", current_price=150.0, open=149.0, high=151.0, low=148.0, close=150.0, volume=1000, ) weights = {"momentum": 0.5, "news_driven": 0.3, "mean_reversion": 0.2} signal = await ensemble.evaluate("AAPL", market, None, weights) assert signal is not None # Should have exactly 2 sources assert len(signal.strategy_sources) == 2 source_names = [s.split(":")[0] for s in signal.strategy_sources] assert "momentum" in source_names assert "news_driven" in source_names # mean_reversion should NOT be present assert "mean_reversion" not in source_names @pytest.mark.asyncio async def test_strategy_sources_contain_direction_and_strength(self): """Each source tag should be formatted as name:DIRECTION:strength.""" s1 = _StubStrategy("alpha", _make_signal(SignalDirection.LONG, 0.75)) ensemble = WeightedEnsemble([s1], threshold=0.0) market = MarketSnapshot( ticker="AAPL", current_price=150.0, open=149.0, high=151.0, low=148.0, close=150.0, volume=1000, ) weights = {"alpha": 1.0} signal = await ensemble.evaluate("AAPL", market, None, weights) assert signal is not None assert len(signal.strategy_sources) == 1 parts = signal.strategy_sources[0].split(":") assert parts[0] == "alpha" assert parts[1] == "LONG" assert float(parts[2]) == pytest.approx(0.75, abs=0.01)