389 lines
14 KiB
Python
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
|