Files

389 lines
14 KiB
Python

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