| 1 | import pytest |
| 2 | from datetime import datetime, timedelta, timezone |
| 3 | from decimal import Decimal |
| 4 | from types import SimpleNamespace |
| 5 | from uuid import uuid4 |
| 6 | import app.main as main |
| 7 | |
| 8 | @pytest.mark.asyncio |
| 9 | async def test_sector_route_joins_only_active_universe_and_skips_missing_inputs(monkeypatch): |
| 10 | valid, missing, research_only = uuid4(), uuid4(), uuid4() |
| 11 | class Fact: value = "Technology" |
| 12 | class Snapshot: facts = {"sector": Fact()} |
| 13 | class Record: snapshot = Snapshot() |
| 14 | class Score: overall_score = 80; category_evidence = {} |
| 15 | observed = {} |
| 16 | async def active_global_equities(**kwargs): |
| 17 | observed.update(kwargs) |
| 18 | return [ |
| 19 | {"globalInstrumentId": str(valid), "canonicalName": "Valid", "ticker": "VAL", "exchange": "XNAS", "country": "US", "currency": "USD"}, |
| 20 | {"globalInstrumentId": str(missing), "canonicalName": "Missing", "ticker": "MISS", "exchange": "XNAS", "country": "US", "currency": "USD"}, |
| 21 | ] |
| 22 | monkeypatch.setattr(main.portfolio_orchestrator, "active_global_equities", active_global_equities) |
| 23 | monkeypatch.setattr(main.repository, "profile", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("leaderboard must not require a process-local profile"))) |
| 24 | monkeypatch.setattr(main.repository, "summary", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("leaderboard must not use profile-bound summary"))) |
| 25 | monkeypatch.setattr(main.repository, "persisted_canonical_read_model_score", lambda key: Score() if key == valid else None) |
| 26 | monkeypatch.setattr(main.repository, "structured_market_snapshots_for", lambda _ids: {valid: [Record()]}) |
| 27 | result = await main.sector_leaderboard(x_correlation_id="test-correlation", x_aip_user_id="test-user") |
| 28 | assert result["sectors"][0]["stocks"][0]["globalInstrumentId"] == str(valid) |
| 29 | assert str(research_only) not in str(result) |
| 30 | assert observed["correlation_id"] == "test-correlation" |
| 31 | assert observed["identity_headers"]["X-AIP-User-Id"] == "test-user" |
| 32 | |
| 33 | async def _async(value): return value |
| 34 | |
| 35 | |
| 36 | @pytest.mark.asyncio |
| 37 | async def test_sector_performance_route_uses_authenticated_universe_and_persisted_prices_only(monkeypatch): |
| 38 | instrument_id = uuid4() |
| 39 | class Fact: value = "Technology" |
| 40 | class Snapshot: facts = {"sector": Fact()} |
| 41 | class Record: snapshot = Snapshot() |
| 42 | observed = {} |
| 43 | async def active_global_equities(**kwargs): |
| 44 | observed.update(kwargs) |
| 45 | return [{"globalInstrumentId": str(instrument_id), "canonicalName": "Persisted", "ticker": "PST", "exchange": "XETR", "country": "DE", "currency": "EUR"}] |
| 46 | now = datetime(2026, 9, 7, tzinfo=timezone.utc) |
| 47 | prices = [ |
| 48 | SimpleNamespace(observed_at=now - timedelta(days=8), price=Decimal("100"), currency="EUR"), |
| 49 | SimpleNamespace(observed_at=now, price=Decimal("110"), currency="EUR"), |
| 50 | ] |
| 51 | monkeypatch.setattr(main.portfolio_orchestrator, "active_global_equities", active_global_equities) |
| 52 | monkeypatch.setattr(main.repository, "structured_market_snapshots_for", lambda ids: {instrument_id: [Record()]}) |
| 53 | monkeypatch.setattr(main.repository, "market_price_observations_for", lambda ids: {instrument_id: prices}) |
| 54 | monkeypatch.setattr(main.repository, "persisted_canonical_read_model_score", lambda *_args: (_ for _ in ()).throw(AssertionError("performance must not use research score"))) |
| 55 | result = await main.sector_performance(region="EUROPE", sector="Technology", period="WEEK", limit=5, x_correlation_id="performance-test", x_aip_user_id="test-user") |
| 56 | assert result["bestPerformers"][0]["globalInstrumentId"] == str(instrument_id) |
| 57 | assert result["bestPerformers"][0]["performancePct"] == Decimal("10") |
| 58 | assert result["worstPerformers"][0]["globalInstrumentId"] == str(instrument_id) |
| 59 | assert observed["identity_headers"]["X-AIP-User-Id"] == "test-user" |
| 60 | |
| 61 | |
| 62 | @pytest.mark.asyncio |
| 63 | @pytest.mark.parametrize("sector", [ |
| 64 | "Communication Services", |
| 65 | "Consumer Discretionary", |
| 66 | "Consumer Staples", |
| 67 | "Energy", |
| 68 | "Financials", |
| 69 | "Healthcare", |
| 70 | "Industrials", |
| 71 | "Materials", |
| 72 | "Real Estate", |
| 73 | "Technology", |
| 74 | "Utilities", |
| 75 | ]) |
| 76 | async def test_every_india_canonical_sector_ranks_full_week_universe_before_top_and_worst_limit( |
| 77 | monkeypatch, sector |
| 78 | ): |
| 79 | now = datetime(2026, 9, 8, tzinfo=timezone.utc) |
| 80 | instrument_ids = [uuid4() for _ in range(7)] |
| 81 | payloads = [ |
| 82 | { |
| 83 | "globalInstrumentId": str(instrument_id), |
| 84 | "canonicalName": f"{sector} {index}", |
| 85 | "ticker": f"S{index}", |
| 86 | "exchange": "NSE", |
| 87 | "country": "IN", |
| 88 | "currency": "INR", |
| 89 | "canonicalSector": sector, |
| 90 | } |
| 91 | for index, instrument_id in enumerate(instrument_ids) |
| 92 | ] |
| 93 | listings = [SimpleNamespace(as_payload=lambda value=value: value) for value in payloads] |
| 94 | prices = { |
| 95 | instrument_id: [ |
| 96 | SimpleNamespace(observed_at=now - timedelta(days=8), price=Decimal("100"), currency="INR"), |
| 97 | SimpleNamespace(observed_at=now, price=Decimal(str(101 + index)), currency="INR"), |
| 98 | ] |
| 99 | for index, instrument_id in enumerate(instrument_ids) |
| 100 | } |
| 101 | |
| 102 | async def durable_india_universe(**_kwargs): |
| 103 | return listings |
| 104 | |
| 105 | monkeypatch.setattr(main.india_market_universe_provider, "listings", durable_india_universe) |
| 106 | monkeypatch.setattr(main.repository, "market_price_observations_for", lambda ids: { |
| 107 | instrument_id: prices[instrument_id] for instrument_id in ids |
| 108 | }) |
| 109 | monkeypatch.setattr( |
| 110 | main.repository, |
| 111 | "structured_market_snapshots_for", |
| 112 | lambda _ids: (_ for _ in ()).throw(AssertionError("India sector join is durable upstream")), |
| 113 | ) |
| 114 | |
| 115 | result = await main.sector_performance( |
| 116 | region="INDIA", |
| 117 | sector=sector, |
| 118 | period="WEEK", |
| 119 | limit=5, |
| 120 | x_aip_user_id="test-user", |
| 121 | ) |
| 122 | |
| 123 | assert result["sector"] == sector |
| 124 | assert len(result["bestPerformers"]) == 5 |
| 125 | assert len(result["worstPerformers"]) == 5 |
| 126 | assert [row["globalInstrumentId"] for row in result["bestPerformers"]] == [ |
| 127 | str(value) for value in reversed(instrument_ids[2:]) |
| 128 | ] |
| 129 | assert [row["globalInstrumentId"] for row in result["worstPerformers"]] == [ |
| 130 | str(value) for value in instrument_ids[:5] |
| 131 | ] |