gendesign/tradein-mvp/backend/tests/test_2899_market_percentile.py
bot-backend cf48e6d6c8
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m54s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Successful in 2m6s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
feat(tradein): позиция квартиры внутри когорты аналогов — перцентиль (#2899) (#2926)
2026-08-19 10:08:18 +00:00

101 lines
4.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""#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