"""Tests for trend projection module — forward-looking trend estimates. Tests the pure logic functions (no DB required). Covers momentum computation, macro decay projection, core projection assembly, divergence flagging, macro-disabled behavior, and low-confidence marking. """ from datetime import datetime, timezone from services.aggregation.projection import ( DEFAULT_CONFIDENCE_THRESHOLD, MacroEventInfo, TrendProjection, compute_projection, compute_trend_momentum, project_macro_decay, ) from services.shared.schemas import TrendDirection, TrendSummary, TrendWindow NOW = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc) def _make_summary( direction: TrendDirection = TrendDirection.BULLISH, strength: float = 0.6, confidence: float = 0.7, window: TrendWindow = TrendWindow.SEVEN_DAY, catalysts: list[str] | None = None, ) -> TrendSummary: return TrendSummary( entity_type="company", entity_id="AAPL", window=window, trend_direction=direction, trend_strength=strength, confidence=confidence, dominant_catalysts=catalysts or [], generated_at=NOW, ) def _make_macro_event( impact_score: float = 0.6, direction: str = "negative", estimated_duration: str = "medium_term", severity: str = "high", age_hours: float = 12.0, confidence: float = 0.8, ) -> MacroEventInfo: return MacroEventInfo( event_id="evt-1", macro_impact_score=impact_score, impact_direction=direction, confidence=confidence, estimated_duration=estimated_duration, severity=severity, event_age_hours=age_hours, ) # --------------------------------------------------------------------------- # compute_trend_momentum # --------------------------------------------------------------------------- def test_momentum_no_previous_data_bullish(): """Without previous data, momentum is a heuristic based on current trend.""" m = compute_trend_momentum(0.6, "bullish") assert m > 0.0 assert m <= 1.0 def test_momentum_no_previous_data_bearish(): m = compute_trend_momentum(0.6, "bearish") assert m < 0.0 assert m >= -1.0 def test_momentum_no_previous_data_neutral(): m = compute_trend_momentum(0.3, "neutral") assert m == 0.0 def test_momentum_increasing_bullish(): """Strength increasing in bullish direction → positive momentum.""" m = compute_trend_momentum(0.8, "bullish", 0.4, "bullish") assert m > 0.0 def test_momentum_decreasing_bullish(): """Strength decreasing in bullish direction → negative momentum.""" m = compute_trend_momentum(0.3, "bullish", 0.7, "bullish") assert m < 0.0 def test_momentum_direction_reversal(): """Switching from bullish to bearish → strong negative momentum.""" m = compute_trend_momentum(0.5, "bearish", 0.5, "bullish") assert m < 0.0 assert m <= -0.5 # significant reversal def test_momentum_clamped_to_bounds(): """Momentum should be clamped to [-1, 1].""" m = compute_trend_momentum(1.0, "bullish", 1.0, "bearish") assert -1.0 <= m <= 1.0 # --------------------------------------------------------------------------- # project_macro_decay # --------------------------------------------------------------------------- def test_macro_decay_empty_events(): strength, direction = project_macro_decay([], 7.0) assert strength == 0.0 assert direction == "neutral" def test_macro_decay_short_term_rapid(): """Short-term events decay rapidly (half-life = 1 day).""" event = _make_macro_event( impact_score=0.8, direction="negative", estimated_duration="short_term", severity="high", age_hours=0.0, ) s_1d, _ = project_macro_decay([event], 1.0) s_7d, _ = project_macro_decay([event], 7.0) # After 7 days, short-term event should be much weaker assert s_7d < s_1d def test_macro_decay_long_term_slow(): """Long-term events decay slowly (half-life = 30 days).""" event = _make_macro_event( impact_score=0.8, direction="negative", estimated_duration="long_term", severity="high", age_hours=0.0, ) s_1d, _ = project_macro_decay([event], 1.0) s_7d, _ = project_macro_decay([event], 7.0) # Long-term event should retain most of its strength after 7 days assert s_7d > s_1d * 0.5 def test_macro_decay_direction_negative(): event = _make_macro_event(direction="negative") _, direction = project_macro_decay([event], 7.0) assert direction == "bearish" def test_macro_decay_direction_positive(): event = _make_macro_event(direction="positive") _, direction = project_macro_decay([event], 7.0) assert direction == "bullish" def test_macro_decay_mixed_directions(): """Mixed positive and negative events → mixed direction.""" events = [ _make_macro_event(direction="positive", impact_score=0.5, severity="high"), _make_macro_event(direction="negative", impact_score=0.5, severity="high"), ] _, direction = project_macro_decay(events, 7.0) assert direction == "mixed" # --------------------------------------------------------------------------- # compute_projection — basic behavior # --------------------------------------------------------------------------- def test_projection_basic_bullish(): """A bullish trend with no macro events produces a bullish projection.""" summary = _make_summary(TrendDirection.BULLISH, strength=0.6, confidence=0.7) proj = compute_projection(summary, macro_events=None, macro_enabled=True) assert proj.projected_direction == "bullish" assert 0.0 <= proj.projected_strength <= 1.0 assert 0.0 <= proj.projected_confidence <= 1.0 assert proj.projection_horizon == "7d" assert len(proj.driving_factors) > 0 assert proj.diverges_from_current is False def test_projection_basic_bearish(): summary = _make_summary(TrendDirection.BEARISH, strength=0.5, confidence=0.6) proj = compute_projection(summary, macro_events=None, macro_enabled=True) assert proj.projected_direction == "bearish" assert proj.diverges_from_current is False def test_projection_neutral_trend(): summary = _make_summary(TrendDirection.NEUTRAL, strength=0.0, confidence=0.5) proj = compute_projection(summary, macro_events=None, macro_enabled=True) assert 0.0 <= proj.projected_strength <= 1.0 assert len(proj.driving_factors) > 0 def test_projection_horizon_from_window(): """Projection horizon should match the trend window.""" for window, expected_horizon in [ (TrendWindow.ONE_DAY, "1d"), (TrendWindow.SEVEN_DAY, "7d"), (TrendWindow.THIRTY_DAY, "30d"), (TrendWindow.NINETY_DAY, "30d"), (TrendWindow.INTRADAY, "1d"), ]: summary = _make_summary(window=window) proj = compute_projection(summary) assert proj.projection_horizon == expected_horizon # --------------------------------------------------------------------------- # compute_projection — divergence flagging # --------------------------------------------------------------------------- def test_projection_divergence_flagged(): """When macro signals push projection opposite to current trend, flag divergence.""" summary = _make_summary(TrendDirection.BULLISH, strength=0.3, confidence=0.6) # Strong negative macro events should push projection bearish events = [ _make_macro_event(impact_score=0.9, direction="negative", severity="critical", age_hours=2.0, estimated_duration="medium_term"), ] proj = compute_projection(summary, macro_events=events, macro_enabled=True) if proj.projected_direction != "bullish": assert proj.diverges_from_current is True assert any("DIVERGENCE" in f for f in proj.driving_factors) def test_projection_no_divergence_when_aligned(): """When macro signals align with current trend, no divergence.""" summary = _make_summary(TrendDirection.BEARISH, strength=0.5, confidence=0.7) events = [ _make_macro_event(impact_score=0.7, direction="negative", severity="high", age_hours=6.0), ] proj = compute_projection(summary, macro_events=events, macro_enabled=True) assert proj.projected_direction == "bearish" assert proj.diverges_from_current is False # --------------------------------------------------------------------------- # compute_projection — macro disabled # --------------------------------------------------------------------------- def test_projection_macro_disabled_reduced_confidence(): """With macro disabled, projection confidence should be reduced.""" summary = _make_summary(TrendDirection.BULLISH, strength=0.6, confidence=0.8) events = [_make_macro_event(impact_score=0.5, direction="negative")] proj_enabled = compute_projection( summary, macro_events=events, macro_enabled=True, ) proj_disabled = compute_projection( summary, macro_events=events, macro_enabled=False, ) assert proj_disabled.projected_confidence <= proj_enabled.projected_confidence def test_projection_macro_disabled_zero_macro_contribution(): """With macro disabled, macro_contribution_pct should be 0.""" summary = _make_summary(TrendDirection.BULLISH, strength=0.6, confidence=0.7) events = [_make_macro_event()] proj = compute_projection(summary, macro_events=events, macro_enabled=False) assert proj.macro_contribution_pct == 0.0 def test_projection_macro_disabled_still_produces_projection(): """Even with macro disabled, a projection is always produced.""" summary = _make_summary(TrendDirection.BULLISH, strength=0.5, confidence=0.6) proj = compute_projection(summary, macro_events=None, macro_enabled=False) assert proj.projected_direction in {"bullish", "bearish", "mixed", "neutral"} assert 0.0 <= proj.projected_strength <= 1.0 assert 0.0 <= proj.projected_confidence <= 1.0 assert len(proj.driving_factors) > 0 # --------------------------------------------------------------------------- # compute_projection — low confidence marking # --------------------------------------------------------------------------- def test_projection_low_confidence_marked(): """Projections below confidence threshold are marked low_confidence.""" summary = _make_summary( TrendDirection.NEUTRAL, strength=0.0, confidence=0.1, ) proj = compute_projection( summary, macro_events=None, macro_enabled=False, confidence_threshold=DEFAULT_CONFIDENCE_THRESHOLD, ) # Very low base confidence → projected confidence should be below threshold assert proj.low_confidence is True def test_projection_above_threshold_not_low_confidence(): """Projections above confidence threshold are NOT marked low_confidence.""" summary = _make_summary( TrendDirection.BULLISH, strength=0.7, confidence=0.9, ) proj = compute_projection( summary, macro_events=None, macro_enabled=True, confidence_threshold=DEFAULT_CONFIDENCE_THRESHOLD, ) assert proj.low_confidence is False # --------------------------------------------------------------------------- # compute_projection — macro contribution # --------------------------------------------------------------------------- def test_projection_macro_contribution_nonzero_with_events(): """When macro events are present and enabled, macro_contribution_pct > 0.""" summary = _make_summary(TrendDirection.BULLISH, strength=0.5, confidence=0.7) events = [ _make_macro_event(impact_score=0.7, direction="negative", severity="high", age_hours=6.0), ] proj = compute_projection(summary, macro_events=events, macro_enabled=True) assert proj.macro_contribution_pct > 0.0 def test_projection_macro_contribution_zero_without_events(): """Without macro events, macro_contribution_pct should be 0.""" summary = _make_summary(TrendDirection.BULLISH, strength=0.5, confidence=0.7) proj = compute_projection(summary, macro_events=None, macro_enabled=True) assert proj.macro_contribution_pct == 0.0 # --------------------------------------------------------------------------- # compute_projection — catalysts # --------------------------------------------------------------------------- def test_projection_with_upcoming_catalysts(): """Upcoming catalysts should appear in driving_factors.""" summary = _make_summary(TrendDirection.BULLISH, strength=0.5, confidence=0.7) proj = compute_projection( summary, macro_events=None, macro_enabled=True, upcoming_catalysts=["Q4 earnings report", "FDA approval decision"], ) factor_text = " ".join(proj.driving_factors) assert "Q4 earnings report" in factor_text assert "FDA approval decision" in factor_text # --------------------------------------------------------------------------- # TrendProjection dataclass # --------------------------------------------------------------------------- def test_trend_projection_defaults(): """TrendProjection should have sensible defaults.""" proj = TrendProjection() assert proj.projected_direction == "neutral" assert proj.projected_strength == 0.5 assert proj.projected_confidence == 0.5 assert proj.projection_horizon == "7d" assert proj.driving_factors == [] assert proj.macro_contribution_pct == 0.0 assert proj.diverges_from_current is False assert proj.low_confidence is False def test_projection_strength_bounds(): """Projected strength should always be in [0, 1].""" # Test with extreme inputs summary = _make_summary(TrendDirection.BULLISH, strength=1.0, confidence=1.0) events = [ _make_macro_event(impact_score=1.0, direction="positive", severity="critical", age_hours=0.0), ] proj = compute_projection( summary, macro_events=events, macro_enabled=True, previous_strength=0.0, previous_direction="bearish", ) assert 0.0 <= proj.projected_strength <= 1.0 assert 0.0 <= proj.projected_confidence <= 1.0