| 1 | from datetime import datetime, timedelta, timezone |
| 2 | from decimal import Decimal |
| 3 | from types import SimpleNamespace |
| 4 | |
| 5 | import pytest |
| 6 | |
| 7 | from app.sector_performance import belongs_to_region, performance_window, rank_performers, rank_top_gainers |
| 8 | |
| 9 | |
| 10 | def observation(days_ago, price): |
| 11 | return SimpleNamespace(observed_at=datetime(2026, 9, 7, tzinfo=timezone.utc) - timedelta(days=days_ago), price=Decimal(str(price))) |
| 12 | |
| 13 | |
| 14 | @pytest.mark.parametrize("period,reference_days", [("DAY", 1), ("WEEK", 7), ("MONTH", 30), ("YEAR", 365)]) |
| 15 | def test_performance_uses_nearest_valid_prior_trading_observation(period, reference_days): |
| 16 | latest, reference, pct = performance_window([observation(reference_days + 1, 100), observation(reference_days, 110), observation(0, 121)], period) |
| 17 | assert latest.price == Decimal("121") |
| 18 | assert reference.price == Decimal("110") |
| 19 | assert pct == Decimal("10") |
| 20 | |
| 21 | |
| 22 | def test_missing_or_non_positive_reference_is_ineligible(): |
| 23 | assert performance_window([observation(0, 120)], "WEEK") is None |
| 24 | assert performance_window([observation(8, 0), observation(0, 120)], "WEEK") is None |
| 25 | |
| 26 | |
| 27 | def test_day_uses_previous_observed_trading_close_not_calendar_subtraction(): |
| 28 | # Monday close must compare with Friday's observation; no Sunday quote is invented. |
| 29 | monday = datetime(2026, 9, 7, tzinfo=timezone.utc) |
| 30 | friday = monday - timedelta(days=3) |
| 31 | latest, reference, pct = performance_window([ |
| 32 | SimpleNamespace(observed_at=friday, price=Decimal("100")), |
| 33 | SimpleNamespace(observed_at=monday, price=Decimal("105")), |
| 34 | ], "DAY") |
| 35 | assert reference.observed_at == friday |
| 36 | assert pct == Decimal("5") |
| 37 | |
| 38 | |
| 39 | def test_top_gainers_are_deduplicated_sorted_and_backend_limited(): |
| 40 | rows = [ |
| 41 | {"globalInstrumentId": f"id-{index}", "ticker": f"T{index}", "performancePct": Decimal(10 - index)} |
| 42 | for index in range(7) |
| 43 | ] + [{"globalInstrumentId": "id-0", "ticker": "ZZZ", "performancePct": Decimal("1")}] |
| 44 | result = rank_top_gainers(rows, 99) |
| 45 | assert [row["globalInstrumentId"] for row in result] == ["id-0", "id-1", "id-2", "id-3", "id-4"] |
| 46 | |
| 47 | |
| 48 | def test_equal_performance_has_stable_ticker_then_identity_order(): |
| 49 | result = rank_top_gainers([ |
| 50 | {"globalInstrumentId": "b", "ticker": "BBB", "performancePct": Decimal("4")}, |
| 51 | {"globalInstrumentId": "a", "ticker": "AAA", "performancePct": Decimal("4")}, |
| 52 | ]) |
| 53 | assert [row["globalInstrumentId"] for row in result] == ["a", "b"] |
| 54 | |
| 55 | |
| 56 | def test_best_and_worst_are_independently_limited_and_deterministic(): |
| 57 | rows = [{"globalInstrumentId": f"id-{index}", "ticker": f"T{index}", "performancePct": Decimal(index - 3)} for index in range(7)] |
| 58 | best, worst = rank_performers(rows, 5) |
| 59 | assert [row["performancePct"] for row in best] == [Decimal("3"), Decimal("2"), Decimal("1"), Decimal("0"), Decimal("-1")] |
| 60 | assert [row["performancePct"] for row in worst] == [Decimal("-3"), Decimal("-2"), Decimal("-1"), Decimal("0"), Decimal("1")] |
| 61 | |
| 62 | |
| 63 | def test_region_membership_is_provider_neutral(): |
| 64 | assert belongs_to_region({"country": "IN", "exchange": "XNSE"}, "INDIA") |
| 65 | assert belongs_to_region({"country": "US", "exchange": "XNAS"}, "USA") |
| 66 | assert belongs_to_region({"country": "DE", "exchange": "XETR"}, "EUROPE") |
| 67 | assert not belongs_to_region({"country": "US", "exchange": "XNAS"}, "EUROPE") |