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
101 lines
4.8 KiB
Python
101 lines
4.8 KiB
Python
"""#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
|