gendesign/tradein-mvp/backend/data
bot-backend 2c852bd13b
Some checks failed
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Failing after 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m31s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(tradein): позиция квартиры внутри когорты аналогов (перцентиль)
Макет показывает бейдж «Верх рынка» — место квартиры в распределении аналогов.
Поля не было ни в схеме, ни в расчёте.

ГДЕ СЧИТАЕТСЯ

В estimate_quality, в блоке display_pool — по той же когорте, что дала headline
(anchor_comps_used либо ценовые listings_clean), и по УЖЕ финальной median_ppm2.

Внутри _price_from_inputs считать нельзя: там цена ещё не финальная (восемь
мутаторов — repair_coef, anchor override, IMV-blend, quarter index, corridor
clamp, radius floor, deals fallback, segment multiplier — отрабатывают позже),
а n_analogs после точки расчёта перезаписывается трижды. Перцентиль разошёлся бы
с показанной ценой у двух третей оценок: 733 из 1086 прод-строк идут якорным
путём.

Пул берётся ДО _dedup_display_lots: дедуп режет выборку под показ (на проде
15 карточек из 528 усечены именно им), а позиция должна считаться по популяции,
из которой взята цена.

Формула — перцентиль РАНГА (midrank: доля дешевле плюс половина равных), а не
_percentile: тот решает обратную задачу «какая цена стоит на заданной доле».
Края зажаты в 1..99 — «0-й перцентиль» читался бы как «дешевле всех на свете»,
хотя означает лишь край выборки из двадцати лотов.

ПОРОГ 15, А НЕ 5

Замер 19.08 (Монте-Карло на 54 прод-когортах размера 62..125, с поправкой на
конечную популяцию): k=5 → ошибка ранга 13.4 пп и ярлык перепутан в 26.4%
случаев; k=15 → 7.6 пп; k=20 → 6.7 пп ценой ещё 10 пп покрытия. Второй сигнал:
шаг перцентиля 100/n против ценового разрыва между соседями — при n≈7 один
соседний лот двигает ярлык на целую категорию.

HEADLINE_LISTINGS_MIN_N=5 переиспользовать нельзя: он отвечает на другой вопрос
— устойчива ли МЕДИАНА, а она устойчива там, где ранг ещё пляшет.

Ниже порога поле остаётся None и UI не рисует ничего. Отдельную надпись «мало
данных» не добавляю — это уже говорят reliability и relaxations.

ПЕРСИСТ ОБЯЗАТЕЛЕН

Пересчитать на GET нельзя: когорты в БД нет, в analogs лежит top-10 (прод:
max(jsonb_array_length(analogs))=10 при max(n_analogs)=294). Отсюда миграция 267
и проброс через ОБА SELECT'а — у GET и PDF списки колонок разные и живут в
разных функциях.

getattr в конструкторах — тот же defensive-идиом, что у relaxations/reliability
рядом: строка без колонки деградирует в None, а не роняет ответ.

ЧЕМ ЭТО НЕ ЯВЛЯЕТСЯ

Не location_index_pct (тот про район против медианы города, живёт на другой
ручке и в цену не идёт), не квантили ДКП-коридора, не price_premium_label.

ТЕСТЫ

Двусторонние: против main падают импорт нового модуля тестов (константы нет) и
test_get_estimate_surfaces_market_percentile. Контроли зелёные с обеих сторон:
nullable-поле проходит как null, лоты без цены не засчитываются в порог.

Хунк форматирования — не мой: pre-commit ruff v0.7.4 против 0.15.12 (#2864).

Refs #2899
2026-08-19 14:10:09 +05:00
..
sql feat(tradein): позиция квартиры внутри когорты аналогов (перцентиль) 2026-08-19 14:10:09 +05:00