feat(tradein): позиция квартиры внутри когорты аналогов — перцентиль (#2899) #2926

Merged
bot-backend merged 2 commits from feat/2899-market-percentile into main 2026-08-19 10:08:19 +00:00
6 changed files with 263 additions and 0 deletions

View file

@ -511,6 +511,7 @@ def get_estimate(
f"""
SELECT id, median_price, range_low, range_high, median_price_per_m2,
confidence, confidence_explanation, n_analogs,
market_percentile,
analogs, actual_deals, sources_used, data_freshness_minutes,
expires_at, retain_until, address, lat, lon,
area_m2, rooms, floor, total_floors,
@ -655,6 +656,11 @@ def get_estimate(
confidence=row.confidence,
confidence_explanation=row.confidence_explanation,
n_analogs=row.n_analogs,
# #2899: getattr — тот же defensive-идиом, что у relaxations/reliability
# ниже: строка без колонки (старый in-memory double, любая выборка до
# миграции 267) деградирует в None — «позиции не знаем», — а не роняет
# ответ AttributeError'ом.
market_percentile=getattr(row, "market_percentile", None),
period_months=12,
analogs=analogs,
actual_deals=actual_deals,
@ -732,6 +738,7 @@ def estimate_pdf(
"""
SELECT id, median_price, range_low, range_high, median_price_per_m2,
confidence, confidence_explanation, n_analogs,
market_percentile,
analogs, actual_deals, sources_used, data_freshness_minutes,
expires_at, retain_until,
address, lat, lon, area_m2, rooms, floor, total_floors,
@ -775,6 +782,11 @@ def estimate_pdf(
confidence=row.confidence,
confidence_explanation=row.confidence_explanation,
n_analogs=row.n_analogs,
# #2899: getattr — тот же defensive-идиом, что у relaxations/reliability
# ниже: строка без колонки (старый in-memory double, любая выборка до
# миграции 267) деградирует в None — «позиции не знаем», — а не роняет
# ответ AttributeError'ом.
market_percentile=getattr(row, "market_percentile", None),
# #1351: окно сделок — 12 мес (estimator.DEALS_PERIOD_MONTHS), как в POST
# /estimate и GET /estimate/{id}. Раньше PDF-ветка хардкодила 24 →
# экспортёр рисовал ложный ~2-летний диапазон в клиентском документе.

View file

@ -191,6 +191,14 @@ class AggregatedEstimate(BaseModel):
# #698: ПОЛНОЕ число найденных аналогов — НЕ равно len(analogs) (тот обрезан до
# top-10, см. поле `analogs` ниже). Консьюмер должен брать счёт отсюда, а не из len().
n_analogs: int
# #2899: позиция ЭТОЙ квартиры внутри когорты аналогов, 1..99 — «какая доля
# аналогов дешевле». None = когорта меньше MARKET_PERCENTILE_MIN_N (15) либо
# оценки нет; ниже порога один соседний лот двигает ярлык на целую категорию.
#
# НЕ путать с location_index_pct (`GET /location-index`) — тот про РАЙОН против
# медианы города, а не про квартиру внутри своей выборки, и в цену не идёт.
# Показывать имеет смысл только вместе с n_analogs.
market_percentile: int | None = None
@computed_field # type: ignore[prop-decorator]
@property

View file

@ -184,6 +184,30 @@ DEALS_HEADLINE_FALLBACK_MIN_N = 3
# ИТОГОВОЙ выборке как headline-источнику.
HEADLINE_LISTINGS_MIN_N = 5
# #2899: минимальный размер когорты, при котором ПОЗИЦИЯ объекта в ней (перцентиль)
# перестаёт быть шумом. НЕ переиспользует HEADLINE_LISTINGS_MIN_N=5 намеренно: тот
# отвечает на другой вопрос — «доверять ли выборке как источнику МЕДИАНЫ», а медиана
# устойчива там, где ранг ещё пляшет.
#
# Замер 19.08.2026 (Монте-Карло: из 54 прод-когорт размера 62..125 набирались
# подвыборки размера k, сравнивался ранг объекта в подвыборке с рангом в полной
# когорте; поправка на конечную популяцию учтена):
# k=5 → средняя ошибка ранга 13.4 пп, ярлык «низ/рынок/верх» перепутан в 26.4%
# k=10 → 9.5 пп
# k=15 → 7.6 пп ← точка перелома
# k=20 → 6.7 пп (стоит ещё 10 пп покрытия, даёт ~1 пп точности — не окупается)
# k=30 → 5.4 пп, ярлык перепутан в 10.0%
#
# Согласованный второй сигнал: шаг перцентиля 100/n против ценового разрыва между
# соседями по когорте. При n≈7 шаг 14.3 пп на разрыв 1.8% — один соседний лот двигает
# ярлык на целую категорию; при n≈15 шаг 7.1 пп; при n≈30 — 3.3 пп на 0.8%.
#
# Цена порога: по 1086 историческим оценкам прода n>=15 покрывает 35.8% (n>=5 дало бы
# 76.2%, но с ошибкой ранга вдвое больше). Ниже порога поле остаётся None и UI не
# рисует ничего — «мало данных» уже говорят reliability и relaxations, вторая надпись
# о том же была бы дублированием.
MARKET_PERCENTILE_MIN_N = 15
# #oblast-F (never-block relaxation cascade, product decision 2026-08-10, live
# repro: Академика Парина 46/5 студия 23.1 м² — rooms=1 exact match gave n=4
# и попадала под #oblast-E выше, хотя rooms=0 по тому же адресу давал n=34;
@ -4532,7 +4556,18 @@ async def estimate_quality(
# _dedup_display_lots ловит остаточные кросс-source дубли (ценовой дрейф между
# площадками), которые price_bucket-строгий статистический _dedup_cross_source
# мог пропустить — display-only, n_analogs/median/cv не трогает.
# #2899: позиция объекта внутри ТОЙ ЖЕ когорты, что дала headline, и по УЖЕ
# финальной median_ppm2 (все восемь мутаторов цены — repair_coef, anchor override,
# IMV-blend, quarter index, corridor clamp, radius floor, deals fallback, segment
# multiplier — лежат внутри _price_from_inputs и отработали выше). Считать внутри
# _price_from_inputs нельзя: там цена ещё не финальная, а n_analogs после точки
# расчёта перезаписывается трижды — перцентиль разошёлся бы с показанной ценой у
# двух третей оценок, которые идут якорным путём.
#
# Пул берётся ДО _dedup_display_lots: дедуп режет выборку под показ, а позиция
# должна считаться по популяции, из которой взята цена.
if anchor_tier is not None and anchor_comps_used:
market_percentile = _market_percentile(median_ppm2, anchor_comps_used)
display_pool = _dedup_display_lots(anchor_comps_used)
analogs_lots = [_anchor_comp_to_analog(c) for c in display_pool[:10]]
# #1519: при сработавшем якоре метаданные (freshness/last_scraped_at/
@ -4547,6 +4582,7 @@ async def estimate_quality(
# их сохраняет — «нечего судить»), которые раздували карточки сверх
# заявленного N. _dedup_display_lots — см. ветку anchor выше.
priced_clean = [lot for lot in listings_clean if lot.get("price_per_m2")]
market_percentile = _market_percentile(median_ppm2, priced_clean)
display_pool = _dedup_display_lots(priced_clean)
analogs_lots = [_listing_to_analog(lot) for lot in display_pool[:10]]
metadata_lots = display_pool
@ -4587,6 +4623,7 @@ async def estimate_quality(
ownership_type, has_mortgage,
median_price, range_low, range_high, median_price_per_m2,
confidence, confidence_explanation, n_analogs,
market_percentile,
analogs, actual_deals,
sources_used, data_freshness_minutes,
canonical_address, house_cadnum, house_fias_id,
@ -4606,6 +4643,7 @@ async def estimate_quality(
:ownership_type, :has_mortgage,
:median_price, :range_low, :range_high, :median_ppm2,
:confidence, :explanation, :n_analogs,
:market_percentile,
CAST(:analogs_json AS jsonb),
CAST(:deals_json AS jsonb),
CAST(:sources_json AS jsonb),
@ -4646,6 +4684,7 @@ async def estimate_quality(
"confidence": confidence,
"explanation": explanation,
"n_analogs": n_analogs,
"market_percentile": market_percentile,
"analogs_json": json.dumps(
[a.model_dump(mode="json") for a in analogs_lots], ensure_ascii=False
),
@ -4798,6 +4837,7 @@ async def estimate_quality(
confidence=confidence,
confidence_explanation=explanation,
n_analogs=n_analogs,
market_percentile=market_percentile,
period_months=DEALS_PERIOD_MONTHS,
analogs=analogs_lots,
actual_deals=deals_lots,
@ -6712,6 +6752,36 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
return _union_find_phys_dedup(lots, include_price=True)
def _market_percentile(target_ppm2: float | None, pool: list[dict]) -> int | None:
"""Позиция объекта внутри когорты аналогов, 1..99. PURE.
Перцентиль РАНГА, а не квантиль: отвечает на вопрос «какая доля когорты дешевле
нас», тогда как `_percentile` решает обратную задачу «какая цена стоит на
заданной доле». Формула midrank: доля строго дешевле плюс половина равных, что
даёт 50 для объекта ровно по медиане симметричной когорты и не зависит от того,
попал ли сам объект в пул.
Пул тот же, что дал headline (`anchor_comps_used` либо ценовые `listings_clean`),
и ДО `_dedup_display_lots`: дедуп режет пул под показ (на проде 15 карточек из 528
усечены именно им), а позиция должна считаться по той популяции, из которой взята
цена.
Возвращает None, если считать не по чему или когорта меньше
MARKET_PERCENTILE_MIN_N см. обоснование порога у константы. Зажимаем в 1..99:
«0-й перцентиль» и «100-й» читаются как «дешевле всех на свете», хотя означают
лишь край конкретной выборки.
"""
if target_ppm2 is None or not pool:
return None
prices = [float(lot["price_per_m2"]) for lot in pool if lot.get("price_per_m2") is not None]
if len(prices) < MARKET_PERCENTILE_MIN_N:
return None
below = sum(1 for p in prices if p < target_ppm2)
equal = sum(1 for p in prices if p == target_ppm2)
raw = 100.0 * (below + 0.5 * equal) / len(prices)
return max(1, min(99, round(raw)))
def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Дедуп ОТОБРАЖАЕМЫХ карточек-аналогов по физическому ключу БЕЗ price_bucket.

View file

@ -0,0 +1,30 @@
-- #2899: позиция объекта внутри когорты аналогов (перцентиль ранга, 1..99).
--
-- ЗАЧЕМ КОЛОНКА, А НЕ РАСЧЁТ НА ЧТЕНИИ. Когорты в БД нет: в `analogs` лежит только
-- top-10 показанных лотов (прод: max(jsonb_array_length(analogs)) = 10 при
-- max(n_analogs) = 294, у 599 из 1086 строк список упёрся в потолок). Пересчитать
-- позицию по сохранённому top-10 нельзя — это другая популяция, поэтому значение
-- обязано персиститься вместе с оценкой.
--
-- smallint: диапазон 1..99 по построению (`_market_percentile` зажимает края —
-- «0-й перцентиль» читался бы как «дешевле всех на свете», хотя означает лишь край
-- выборки). NULL = когорта меньше MARKET_PERCENTILE_MIN_N (15) либо оценки нет.
-- Бэкфилла нет и быть не может: позицию старых строк восстановить не из чего.
--
-- ПРО ЛОКИ (#2752). ADD COLUMN без DEFAULT в PG 11+ не переписывает таблицу и держит
-- ACCESS EXCLUSIVE миллисекунды — но ЖДАТЬ его выдачи может сколько угодно, и всё это
-- время ждущий DDL стоит в очереди ПЕРЕД новыми запросами приложения к той же таблице.
-- Ровно так миграция 250 встала на 29 минут за чужой psql-сессией. lock_timeout
-- ограничивает только ожидание: не дождались — красный деплой вместо тихой очереди.
BEGIN;
SET LOCAL lock_timeout = '5s';
ALTER TABLE trade_in_estimates
ADD COLUMN IF NOT EXISTS market_percentile smallint;
COMMENT ON COLUMN trade_in_estimates.market_percentile IS
'#2899: доля аналогов дешевле этой квартиры, 1..99. NULL — когорта < 15 лотов. '
'НЕ location_index_pct (тот про район против медианы города).';
COMMIT;

View file

@ -0,0 +1,101 @@
"""#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

View file

@ -75,6 +75,9 @@ def _make_estimate_row(created_by: str | None, retain_until: object = None) -> S
confidence="medium",
confidence_explanation="ok",
n_analogs=7,
# #2899: колонка есть у всех строк после миграции 267; NULL у старых
# (бэкфилла нет — позицию по сохранённому top-10 не восстановить).
market_percentile=63,
analogs=[],
actual_deals=[],
sources_used=["avito"],
@ -728,3 +731,42 @@ def test_get_estimate_response_includes_retain_until_field(trade_in_app: FastAPI
)
assert resp.status_code == 200
assert resp.json()["retain_until"] is None
def test_get_estimate_surfaces_market_percentile(trade_in_app: FastAPI) -> None:
"""#2899: позиция в когорте переживает перезагрузку по ссылке и уходит в PDF.
Значение считается только на POST (когорты в БД нет в `analogs` лежит top-10,
а не выборка), поэтому оно ОБЯЗАНО храниться в колонке и подниматься обоими SELECT'ами.
У GET и PDF списки колонок РАЗНЫЕ и живут в разных функциях один общий тест их
не покрывает, отсюда две проверки.
"""
row = _make_estimate_row(created_by="kopylov")
db_mock = _make_db_mock(row)
client = _client_with(trade_in_app, db_mock, role="pilot")
resp = client.get(
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
assert resp.json()["market_percentile"] == 63
def test_get_estimate_market_percentile_nullable(trade_in_app: FastAPI) -> None:
"""Контроль: NULL проходит как null, а не роняет ответ.
Так выглядят все строки до миграции 267 и все оценки с когортой меньше 15 лотов.
Зелёный с обеих сторон правки доказывает, что поле необязательное.
"""
row = _make_estimate_row(created_by="kopylov")
row.market_percentile = None
db_mock = _make_db_mock(row)
client = _client_with(trade_in_app, db_mock, role="pilot")
resp = client.get(
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
assert resp.json()["market_percentile"] is None