"""Tests for sector and market rollup aggregation. Tests the pure rollup logic (no DB required). Requirements: 6.3, 6.4, 6.5 """ from datetime import datetime, timezone from services.aggregation.rollups import ( CompanyTrendRow, _build_rollup_disagreement, _derive_rollup_direction, rollup_trends, ) from services.shared.schemas import TrendDirection, TrendWindow NOW = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc) def _make_trend( ticker: str = "AAPL", sector: str = "Technology", window: str = "7d", direction: str = "bullish", strength: float = 0.6, confidence: float = 0.8, contradiction: float = 0.1, catalysts: list[str] | None = None, risks: list[str] | None = None, supporting: list[str] | None = None, opposing: list[str] | None = None, ) -> CompanyTrendRow: return CompanyTrendRow( entity_id=ticker, sector=sector, window=window, trend_direction=direction, trend_strength=strength, confidence=confidence, contradiction_score=contradiction, dominant_catalysts=catalysts or ["earnings"], material_risks=risks or [], top_supporting_evidence=supporting or ["doc-1"], top_opposing_evidence=opposing or [], ) # --------------------------------------------------------------------------- # rollup_trends # --------------------------------------------------------------------------- def test_rollup_empty(): summary = rollup_trends([], "sector", "Technology", "7d", NOW) assert summary.entity_type == "sector" assert summary.entity_id == "Technology" assert summary.trend_direction == TrendDirection.NEUTRAL assert summary.trend_strength == 0.0 assert summary.confidence == 0.0 def test_rollup_single_bullish(): trends = [_make_trend("AAPL", direction="bullish", strength=0.7, confidence=0.9)] summary = rollup_trends(trends, "sector", "Technology", "7d", NOW) assert summary.trend_direction == TrendDirection.BULLISH assert summary.trend_strength > 0 assert summary.confidence > 0 assert summary.window == TrendWindow.SEVEN_DAY def test_rollup_mixed_directions(): trends = [ _make_trend("AAPL", direction="bullish", strength=0.6, confidence=0.8), _make_trend("MSFT", direction="bearish", strength=0.6, confidence=0.8), ] summary = rollup_trends(trends, "sector", "Technology", "7d", NOW) # Equal and opposite → neutral or mixed assert summary.trend_direction in (TrendDirection.NEUTRAL, TrendDirection.MIXED) def test_rollup_confidence_weighted(): """Higher-confidence company should dominate the rollup direction.""" trends = [ _make_trend("AAPL", direction="bullish", strength=0.8, confidence=0.95), _make_trend("MSFT", direction="bearish", strength=0.3, confidence=0.2), ] summary = rollup_trends(trends, "sector", "Technology", "7d", NOW) assert summary.trend_direction == TrendDirection.BULLISH def test_rollup_catalysts_aggregated(): trends = [ _make_trend("AAPL", catalysts=["earnings", "product"], confidence=0.8), _make_trend("MSFT", catalysts=["product", "macro"], confidence=0.6), ] summary = rollup_trends(trends, "sector", "Technology", "7d", NOW) # "product" appears in both → should be top catalyst assert "product" in summary.dominant_catalysts def test_rollup_risks_deduplicated(): trends = [ _make_trend("AAPL", risks=["regulatory risk", "supply chain"], confidence=0.8), _make_trend("MSFT", risks=["Regulatory Risk", "tariffs"], confidence=0.6), ] summary = rollup_trends(trends, "sector", "Technology", "7d", NOW) risk_lower = [r.lower() for r in summary.material_risks] assert risk_lower.count("regulatory risk") == 1 def test_rollup_evidence_collected(): trends = [ _make_trend("AAPL", supporting=["doc-1", "doc-2"], opposing=["doc-3"]), _make_trend("MSFT", supporting=["doc-4"], opposing=["doc-5"]), ] summary = rollup_trends(trends, "market", "all", "7d", NOW) assert "doc-1" in summary.top_supporting_evidence assert "doc-4" in summary.top_supporting_evidence assert "doc-3" in summary.top_opposing_evidence def test_rollup_market_entity_type(): trends = [_make_trend("AAPL"), _make_trend("JPM", sector="Financials")] summary = rollup_trends(trends, "market", "all", "7d", NOW) assert summary.entity_type == "market" assert summary.entity_id == "all" # --------------------------------------------------------------------------- # _derive_rollup_direction # --------------------------------------------------------------------------- def test_derive_direction_bullish(): assert _derive_rollup_direction(0.5, 0.0) == TrendDirection.BULLISH def test_derive_direction_bearish(): assert _derive_rollup_direction(-0.5, 0.0) == TrendDirection.BEARISH def test_derive_direction_neutral(): assert _derive_rollup_direction(0.05, 0.0) == TrendDirection.NEUTRAL def test_derive_direction_mixed_high_contradiction(): assert _derive_rollup_direction(0.1, 0.2) == TrendDirection.MIXED # --------------------------------------------------------------------------- # _build_rollup_disagreement # --------------------------------------------------------------------------- def test_disagreement_no_conflict(): trends = [ _make_trend("AAPL", direction="bullish"), _make_trend("MSFT", direction="bullish"), ] details = _build_rollup_disagreement(trends, "Technology") assert details == [] def test_disagreement_with_conflict(): trends = [ _make_trend("AAPL", direction="bullish", confidence=0.8), _make_trend("MSFT", direction="bearish", confidence=0.7), ] details = _build_rollup_disagreement(trends, "Technology") assert len(details) == 1 assert details[0].dimension == "company_direction" assert "AAPL" in details[0].positive_doc_ids assert "MSFT" in details[0].negative_doc_ids # --------------------------------------------------------------------------- # Macro rollup integration (Requirements 6.1, 6.2, 6.3) # --------------------------------------------------------------------------- from services.aggregation.rollups import ( SECTOR_CONCENTRATION_THRESHOLD, SectorMacroImpact, compute_sector_macro_concentration, ) def _make_sector_macro( sector: str = "Technology", total_impact: float = 1.0, avg_impact: float = 0.5, company_count: int = 2, net_direction: float = -1.0, event_ids: list[str] | None = None, ) -> SectorMacroImpact: return SectorMacroImpact( sector=sector, total_impact=total_impact, avg_impact=avg_impact, company_count=company_count, net_direction=net_direction, event_ids=event_ids or ["evt-1"], ) def test_rollup_no_macro_unchanged(): """Without macro data, rollup output is identical to original behavior.""" trends = [_make_trend("AAPL", direction="bullish", strength=0.7, confidence=0.9)] without_macro = rollup_trends(trends, "sector", "Technology", "7d", NOW) with_none = rollup_trends(trends, "sector", "Technology", "7d", NOW, macro_impacts=None) with_empty = rollup_trends(trends, "sector", "Technology", "7d", NOW, macro_impacts={}) assert without_macro.trend_strength == with_none.trend_strength assert without_macro.trend_strength == with_empty.trend_strength assert without_macro.confidence == with_none.confidence assert without_macro.confidence == with_empty.confidence def test_sector_rollup_with_macro_adjusts_strength(): """Sector rollup with macro data should adjust strength.""" trends = [ _make_trend("AAPL", sector="Technology", direction="bullish", strength=0.5, confidence=0.8), _make_trend("MSFT", sector="Technology", direction="bullish", strength=0.4, confidence=0.7), ] macro = {"Technology": _make_sector_macro("Technology", total_impact=2.0, avg_impact=0.6, company_count=2)} without = rollup_trends(trends, "sector", "Technology", "7d", NOW) with_macro = rollup_trends(trends, "sector", "Technology", "7d", NOW, macro_impacts=macro) # Macro should increase strength assert with_macro.trend_strength >= without.trend_strength def test_sector_rollup_macro_no_match_unchanged(): """Sector rollup with macro data for a different sector is unchanged.""" trends = [_make_trend("AAPL", sector="Technology", direction="bullish", strength=0.5, confidence=0.8)] macro = {"Financials": _make_sector_macro("Financials")} without = rollup_trends(trends, "sector", "Technology", "7d", NOW) with_macro = rollup_trends(trends, "sector", "Technology", "7d", NOW, macro_impacts=macro) assert without.trend_strength == with_macro.trend_strength assert without.confidence == with_macro.confidence def test_market_rollup_with_macro_adjusts(): """Market rollup with macro data should adjust strength and confidence.""" trends = [ _make_trend("AAPL", sector="Technology", direction="bullish", strength=0.5, confidence=0.8), _make_trend("JPM", sector="Financials", direction="bearish", strength=0.4, confidence=0.7), ] macro = { "Technology": _make_sector_macro("Technology", total_impact=1.5, avg_impact=0.5, company_count=1), "Financials": _make_sector_macro("Financials", total_impact=0.5, avg_impact=0.3, company_count=1), } without = rollup_trends(trends, "market", "all", "7d", NOW) with_macro = rollup_trends(trends, "market", "all", "7d", NOW, macro_impacts=macro) # With macro data, at least one of strength or confidence should differ differs = ( with_macro.trend_strength != without.trend_strength or with_macro.confidence != without.confidence ) assert differs def test_market_rollup_disproportionate_sector_surfaced(): """When one sector has >60% of macro impact, it appears in risks or catalysts.""" trends = [ _make_trend("AAPL", sector="Technology", direction="bullish", strength=0.5, confidence=0.8), _make_trend("JPM", sector="Financials", direction="bullish", strength=0.4, confidence=0.7), ] # Technology has 90% of total macro impact macro = { "Technology": _make_sector_macro("Technology", total_impact=9.0, avg_impact=0.9, company_count=1, net_direction=-1.0), "Financials": _make_sector_macro("Financials", total_impact=1.0, avg_impact=0.1, company_count=1, net_direction=0.5), } summary = rollup_trends(trends, "market", "all", "7d", NOW, macro_impacts=macro) # Technology should appear in material_risks (negative direction) or dominant_catalysts all_labels = summary.material_risks + summary.dominant_catalysts tech_found = any("Technology" in label for label in all_labels) assert tech_found, f"Expected Technology in risks/catalysts, got: {all_labels}" def test_market_rollup_no_disproportionate_sector(): """When no sector has >60% of macro impact, no macro labels are surfaced.""" trends = [ _make_trend("AAPL", sector="Technology", direction="bullish", strength=0.5, confidence=0.8), _make_trend("JPM", sector="Financials", direction="bullish", strength=0.4, confidence=0.7), ] # Even split: 50/50 macro = { "Technology": _make_sector_macro("Technology", total_impact=5.0, avg_impact=0.5, company_count=1), "Financials": _make_sector_macro("Financials", total_impact=5.0, avg_impact=0.5, company_count=1), } summary = rollup_trends(trends, "market", "all", "7d", NOW, macro_impacts=macro) all_labels = summary.material_risks + summary.dominant_catalysts macro_labels = [l for l in all_labels if l.startswith("Macro:")] assert len(macro_labels) == 0 # --------------------------------------------------------------------------- # compute_sector_macro_concentration # --------------------------------------------------------------------------- def test_concentration_empty(): assert compute_sector_macro_concentration({}) == [] def test_concentration_single_sector(): impacts = {"Technology": _make_sector_macro("Technology", total_impact=5.0)} result = compute_sector_macro_concentration(impacts) assert len(result) == 1 assert result[0] == ("Technology", 1.0) def test_concentration_multiple_sectors(): impacts = { "Technology": _make_sector_macro("Technology", total_impact=7.0), "Financials": _make_sector_macro("Financials", total_impact=3.0), } result = compute_sector_macro_concentration(impacts) assert result[0][0] == "Technology" assert abs(result[0][1] - 0.7) < 0.01 assert result[1][0] == "Financials" assert abs(result[1][1] - 0.3) < 0.01 def test_concentration_threshold_boundary(): """Exactly at 60% should not be considered disproportionate (>60% required).""" impacts = { "Technology": _make_sector_macro("Technology", total_impact=6.0), "Financials": _make_sector_macro("Financials", total_impact=4.0), } result = compute_sector_macro_concentration(impacts) # 60% is exactly at threshold, not above it assert result[0][1] <= SECTOR_CONCENTRATION_THRESHOLD def test_concentration_above_threshold(): impacts = { "Technology": _make_sector_macro("Technology", total_impact=7.0), "Financials": _make_sector_macro("Financials", total_impact=3.0), } result = compute_sector_macro_concentration(impacts) assert result[0][1] > SECTOR_CONCENTRATION_THRESHOLD