phase 14-15: docker build validation and helm deployment
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
"""Tests for aggregation worker — rolling window trend summary computation.
|
||||
|
||||
Tests the pure logic functions (no DB required). The async DB functions
|
||||
are covered by integration tests.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from services.aggregation.scoring import (
|
||||
ScoringConfig,
|
||||
WeightedSignal,
|
||||
compute_signal_weight,
|
||||
)
|
||||
from services.aggregation.worker import (
|
||||
AggregationConfig,
|
||||
AssembledTrend,
|
||||
ImpactRow,
|
||||
assemble_trend_summary,
|
||||
assemble_trend_with_evidence,
|
||||
build_weighted_signals,
|
||||
compute_contradiction_score,
|
||||
compute_trend_confidence,
|
||||
derive_trend_direction,
|
||||
extract_catalysts_and_risks,
|
||||
rank_evidence,
|
||||
)
|
||||
from services.shared.schemas import MarketContext, TrendDirection, TrendWindow
|
||||
|
||||
NOW = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _make_impact(
|
||||
doc_id: str = "doc-1",
|
||||
sentiment: str = "positive",
|
||||
impact_score: float = 0.7,
|
||||
catalyst_type: str = "earnings",
|
||||
confidence: float = 0.8,
|
||||
source_credibility: float = 0.8,
|
||||
novelty_score: float = 0.5,
|
||||
published_at: datetime | None = None,
|
||||
risks: list[str] | None = None,
|
||||
) -> ImpactRow:
|
||||
return ImpactRow(
|
||||
document_id=doc_id,
|
||||
confidence=confidence,
|
||||
novelty_score=novelty_score,
|
||||
source_credibility=source_credibility,
|
||||
sentiment=sentiment,
|
||||
impact_score=impact_score,
|
||||
catalyst_type=catalyst_type,
|
||||
key_facts=["some fact"],
|
||||
risks=risks or [],
|
||||
published_at=published_at or NOW - timedelta(hours=1),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_weighted_signals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_weighted_signals_basic():
|
||||
impacts = [_make_impact("d1"), _make_impact("d2", sentiment="negative")]
|
||||
signals = build_weighted_signals(impacts, NOW, "7d")
|
||||
assert len(signals) == 2
|
||||
assert signals[0].document_id == "d1"
|
||||
assert signals[0].sentiment_value == 1.0
|
||||
assert signals[1].sentiment_value == -1.0
|
||||
assert all(s.weight.combined > 0 for s in signals)
|
||||
|
||||
|
||||
def test_build_weighted_signals_low_confidence_gated():
|
||||
impacts = [_make_impact("d1", confidence=0.1)]
|
||||
signals = build_weighted_signals(impacts, NOW, "7d")
|
||||
assert signals[0].weight.combined == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# derive_trend_direction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_direction_bullish():
|
||||
assert derive_trend_direction(0.5) == TrendDirection.BULLISH
|
||||
|
||||
|
||||
def test_direction_bearish():
|
||||
assert derive_trend_direction(-0.5) == TrendDirection.BEARISH
|
||||
|
||||
|
||||
def test_direction_neutral():
|
||||
assert derive_trend_direction(0.05) == TrendDirection.NEUTRAL
|
||||
|
||||
|
||||
def test_direction_mixed_high_contradiction():
|
||||
assert derive_trend_direction(0.1, contradiction_score=0.2) == TrendDirection.MIXED
|
||||
|
||||
|
||||
def test_direction_bullish_despite_contradiction():
|
||||
"""Strong sentiment overrides contradiction."""
|
||||
assert derive_trend_direction(0.5, contradiction_score=0.3) == TrendDirection.BULLISH
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_contradiction_score
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_contradiction_no_signals():
|
||||
assert compute_contradiction_score([]) == 0.0
|
||||
|
||||
|
||||
def test_contradiction_all_positive():
|
||||
sw = compute_signal_weight(NOW, NOW, "7d", 0.8, extraction_confidence=0.8)
|
||||
signals = [
|
||||
WeightedSignal("d1", sw, sentiment_value=1.0, impact_score=0.5),
|
||||
WeightedSignal("d2", sw, sentiment_value=1.0, impact_score=0.5),
|
||||
]
|
||||
assert compute_contradiction_score(signals) == 0.0
|
||||
|
||||
|
||||
def test_contradiction_equal_opposing():
|
||||
sw = compute_signal_weight(NOW, NOW, "7d", 0.8, extraction_confidence=0.8)
|
||||
signals = [
|
||||
WeightedSignal("d1", sw, sentiment_value=1.0, impact_score=0.5),
|
||||
WeightedSignal("d2", sw, sentiment_value=-1.0, impact_score=0.5),
|
||||
]
|
||||
score = compute_contradiction_score(signals)
|
||||
assert abs(score - 0.5) < 1e-4
|
||||
|
||||
|
||||
def test_contradiction_mostly_positive():
|
||||
sw = compute_signal_weight(NOW, NOW, "7d", 0.8, extraction_confidence=0.8)
|
||||
signals = [
|
||||
WeightedSignal("d1", sw, sentiment_value=1.0, impact_score=0.8),
|
||||
WeightedSignal("d2", sw, sentiment_value=1.0, impact_score=0.8),
|
||||
WeightedSignal("d3", sw, sentiment_value=-1.0, impact_score=0.3),
|
||||
]
|
||||
score = compute_contradiction_score(signals)
|
||||
assert 0.0 < score < 0.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rank_evidence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rank_evidence_separates_supporting_opposing():
|
||||
sw = compute_signal_weight(NOW, NOW, "7d", 0.8, extraction_confidence=0.8)
|
||||
signals = [
|
||||
WeightedSignal("pos1", sw, sentiment_value=1.0, impact_score=0.9),
|
||||
WeightedSignal("pos2", sw, sentiment_value=1.0, impact_score=0.3),
|
||||
WeightedSignal("neg1", sw, sentiment_value=-1.0, impact_score=0.7),
|
||||
WeightedSignal("neutral1", sw, sentiment_value=0.0, impact_score=0.5),
|
||||
]
|
||||
supporting, opposing = rank_evidence(signals)
|
||||
assert supporting == ["pos1", "pos2"]
|
||||
assert opposing == ["neg1"]
|
||||
|
||||
|
||||
def test_rank_evidence_respects_max():
|
||||
sw = compute_signal_weight(NOW, NOW, "7d", 0.8, extraction_confidence=0.8)
|
||||
signals = [
|
||||
WeightedSignal(f"d{i}", sw, sentiment_value=1.0, impact_score=0.5)
|
||||
for i in range(20)
|
||||
]
|
||||
supporting, opposing = rank_evidence(signals, max_refs=3)
|
||||
assert len(supporting) == 3
|
||||
assert len(opposing) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_catalysts_and_risks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_extract_catalysts_and_risks():
|
||||
impacts = [
|
||||
_make_impact("d1", catalyst_type="earnings", risks=["regulatory risk"]),
|
||||
_make_impact("d2", catalyst_type="earnings", risks=["supply chain"]),
|
||||
_make_impact("d3", catalyst_type="product", risks=["regulatory risk"]),
|
||||
]
|
||||
signals = build_weighted_signals(impacts, NOW, "7d")
|
||||
catalysts, risks = extract_catalysts_and_risks(impacts, signals)
|
||||
assert catalysts[0] == "earnings" # highest cumulative weight
|
||||
assert "product" in catalysts
|
||||
# Risks should be deduplicated
|
||||
risk_lower = [r.lower() for r in risks]
|
||||
assert risk_lower.count("regulatory risk") == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_trend_confidence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_confidence_no_signals():
|
||||
assert compute_trend_confidence([], 0.0) == 0.0
|
||||
|
||||
|
||||
def test_confidence_increases_with_more_signals():
|
||||
sw = compute_signal_weight(NOW, NOW, "7d", 0.8, extraction_confidence=0.8)
|
||||
few = [WeightedSignal(f"d{i}", sw, 1.0, 0.5) for i in range(2)]
|
||||
many = [WeightedSignal(f"d{i}", sw, 1.0, 0.5) for i in range(15)]
|
||||
c_few = compute_trend_confidence(few, 0.0)
|
||||
c_many = compute_trend_confidence(many, 0.0)
|
||||
assert c_many > c_few
|
||||
|
||||
|
||||
def test_confidence_penalized_by_contradiction():
|
||||
sw = compute_signal_weight(NOW, NOW, "7d", 0.8, extraction_confidence=0.8)
|
||||
signals = [WeightedSignal(f"d{i}", sw, 1.0, 0.5) for i in range(5)]
|
||||
c_low = compute_trend_confidence(signals, 0.0)
|
||||
c_high = compute_trend_confidence(signals, 0.5)
|
||||
assert c_high < c_low
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# assemble_trend_summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_assemble_trend_summary_bullish():
|
||||
impacts = [
|
||||
_make_impact("d1", sentiment="positive", impact_score=0.8),
|
||||
_make_impact("d2", sentiment="positive", impact_score=0.6),
|
||||
]
|
||||
signals = build_weighted_signals(impacts, NOW, "7d")
|
||||
summary = assemble_trend_summary("AAPL", "7d", signals, impacts, reference_time=NOW)
|
||||
|
||||
assert summary.entity_id == "AAPL"
|
||||
assert summary.window == TrendWindow.SEVEN_DAY
|
||||
assert summary.trend_direction == TrendDirection.BULLISH
|
||||
assert summary.trend_strength > 0
|
||||
assert summary.confidence > 0
|
||||
assert len(summary.top_supporting_evidence) > 0
|
||||
assert summary.generated_at == NOW
|
||||
|
||||
|
||||
def test_assemble_trend_summary_mixed():
|
||||
impacts = [
|
||||
_make_impact("d1", sentiment="positive", impact_score=0.5),
|
||||
_make_impact("d2", sentiment="negative", impact_score=0.5),
|
||||
]
|
||||
signals = build_weighted_signals(impacts, NOW, "7d")
|
||||
summary = assemble_trend_summary("TSLA", "7d", signals, impacts, reference_time=NOW)
|
||||
|
||||
# Equal opposing signals → neutral or mixed
|
||||
assert summary.trend_direction in (TrendDirection.NEUTRAL, TrendDirection.MIXED)
|
||||
assert summary.contradiction_score > 0
|
||||
|
||||
|
||||
def test_assemble_trend_summary_empty():
|
||||
summary = assemble_trend_summary("AAPL", "7d", [], [], reference_time=NOW)
|
||||
assert summary.trend_direction == TrendDirection.NEUTRAL
|
||||
assert summary.trend_strength == 0.0
|
||||
assert summary.confidence == 0.0
|
||||
|
||||
|
||||
def test_assemble_trend_summary_with_market_context():
|
||||
impacts = [_make_impact("d1")]
|
||||
ctx = MarketContext(ticker="AAPL", volatility=3.0, bars_available=5)
|
||||
signals = build_weighted_signals(impacts, NOW, "7d", market_ctx=ctx)
|
||||
summary = assemble_trend_summary("AAPL", "7d", signals, impacts, market_ctx=ctx, reference_time=NOW)
|
||||
assert summary.market_context is not None
|
||||
assert summary.market_context.ticker == "AAPL"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AggregationConfig
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_aggregation_config_defaults():
|
||||
cfg = AggregationConfig()
|
||||
assert len(cfg.effective_windows()) == len(TrendWindow)
|
||||
assert isinstance(cfg.effective_scoring(), ScoringConfig)
|
||||
|
||||
|
||||
def test_aggregation_config_custom_windows():
|
||||
cfg = AggregationConfig(windows=["7d", "30d"])
|
||||
assert cfg.effective_windows() == ["7d", "30d"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# assemble_trend_with_evidence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_assemble_trend_with_evidence_returns_ranked_details():
|
||||
impacts = [
|
||||
_make_impact("d1", sentiment="positive", impact_score=0.8),
|
||||
_make_impact("d2", sentiment="negative", impact_score=0.6),
|
||||
_make_impact("d3", sentiment="positive", impact_score=0.5),
|
||||
]
|
||||
signals = build_weighted_signals(impacts, NOW, "7d")
|
||||
result = assemble_trend_with_evidence("AAPL", "7d", signals, impacts, reference_time=NOW)
|
||||
|
||||
assert isinstance(result, AssembledTrend)
|
||||
assert result.summary.entity_id == "AAPL"
|
||||
# Supporting evidence should contain the positive docs
|
||||
assert len(result.supporting_evidence) == 2
|
||||
assert all(e.sentiment_value > 0 for e in result.supporting_evidence)
|
||||
# Opposing evidence should contain the negative doc
|
||||
assert len(result.opposing_evidence) == 1
|
||||
assert result.opposing_evidence[0].document_id == "d2"
|
||||
# Rank scores should be positive
|
||||
assert all(e.rank_score > 0 for e in result.supporting_evidence)
|
||||
assert all(e.rank_score > 0 for e in result.opposing_evidence)
|
||||
# Summary evidence IDs should match
|
||||
assert result.summary.top_supporting_evidence == [e.document_id for e in result.supporting_evidence]
|
||||
assert result.summary.top_opposing_evidence == [e.document_id for e in result.opposing_evidence]
|
||||
|
||||
|
||||
def test_assemble_trend_with_evidence_empty_signals():
|
||||
result = assemble_trend_with_evidence("AAPL", "7d", [], [], reference_time=NOW)
|
||||
assert result.supporting_evidence == []
|
||||
assert result.opposing_evidence == []
|
||||
assert result.summary.trend_direction == TrendDirection.NEUTRAL
|
||||
Reference in New Issue
Block a user