"""#2899: позиция объекта внутри когорты аналогов (перцентиль ранга). Макет показывает бейдж «Верх рынка» — позицию квартиры в распределении аналогов. Поля не было ни в схеме, ни в расчёте. Порог MARKET_PERCENTILE_MIN_N=15 взят из замера 19.08.2026 (Монте-Карло на 54 прод-когортах): при выборке в 5 лотов средняя ошибка ранга 13.4 пп и ярлык «низ/рынок/верх» перепутан в 26.4% случаев; при 15 — 7.6 пп. Порог 5 от HEADLINE_LISTINGS_MIN_N переиспользовать нельзя: тот отвечает на другой вопрос — устойчива ли МЕДИАНА, а она устойчива там, где ранг ещё пляшет. """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from app.services.estimator import ( MARKET_PERCENTILE_MIN_N, _market_percentile, ) def _pool(prices: list[float]) -> list[dict]: return [{"price_per_m2": p} for p in prices] # ── порог ──────────────────────────────────────────────────────────────────── def test_below_threshold_returns_none() -> None: """Когорта на один лот меньше порога — позиции нет, а не «примерно такая».""" prices = [100_000 + i * 1_000 for i in range(MARKET_PERCENTILE_MIN_N - 1)] assert _market_percentile(120_000, _pool(prices)) is None def test_at_threshold_returns_number() -> None: """Ровно на пороге — считаем. Граница включающая, без «почти хватило».""" prices = [100_000 + i * 1_000 for i in range(MARKET_PERCENTILE_MIN_N)] assert _market_percentile(200_000, _pool(prices)) == 99 def test_threshold_counts_only_priced_lots() -> None: """Лоты без цены в счёт не идут: 14 ценовых + 5 пустых — это 14, а не 19. Радиусная ветка отбирает `price_per_m2`-лоты сама, но якорные комплы приходят как есть, и без этой проверки порог обошёлся бы пустышками. """ prices = [{"price_per_m2": 100_000 + i} for i in range(MARKET_PERCENTILE_MIN_N - 1)] empty = [{"price_per_m2": None} for _ in range(5)] assert _market_percentile(100_000, prices + empty) is None # ── сама позиция ───────────────────────────────────────────────────────────── def test_median_object_lands_mid_scale() -> None: """Объект ровно по медиане симметричной когорты → около 50.""" prices = [float(x) for x in range(100, 100 + 21)] # 100..120, медиана 110 assert _market_percentile(110.0, _pool(prices)) == 50 def test_expensive_object_is_high() -> None: prices = [float(x) for x in range(100, 100 + 20)] assert _market_percentile(1000.0, _pool(prices)) == 99 def test_cheap_object_is_low() -> None: prices = [float(x) for x in range(100, 100 + 20)] assert _market_percentile(1.0, _pool(prices)) == 1 def test_edges_are_clamped_not_zero_or_hundred() -> None: """1..99, а не 0..100. «0-й перцентиль» читается как «дешевле всех на свете», хотя означает лишь край конкретной выборки из 20 лотов. Край выборки — не край рынка. """ prices = [float(x) for x in range(100, 100 + 20)] assert _market_percentile(0.0, _pool(prices)) == 1 assert _market_percentile(1e9, _pool(prices)) == 99 def test_ties_count_as_half() -> None: """Равные цены дают midrank: 10 дешевле, 10 равных → 50, а не 33 и не 66.""" prices = [100.0] * 10 + [200.0] * 10 assert _market_percentile(200.0, _pool(prices)) == 75 prices2 = [200.0] * 20 assert _market_percentile(200.0, _pool(prices2)) == 50 # ── вырожденные входы ──────────────────────────────────────────────────────── def test_no_price_returns_none() -> None: assert _market_percentile(None, _pool([float(x) for x in range(100, 120)])) is None def test_empty_pool_returns_none() -> None: assert _market_percentile(100_000.0, []) is None