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

2 commits

Author SHA1 Message Date
9f17983689 fix(tradein): миграция 267 — lock_timeout, как требует гейт #2752
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m36s
Гейт `check-migration-lock-timeout.py` в ci.yml поймал правильно: `ADD COLUMN`
без `SET LOCAL lock_timeout` — блокирующий DDL.

Мой комментарий в первой версии был вдобавок обманчив: «блокировки не берёт,
ADD COLUMN без DEFAULT не переписывает таблицу». Переписывания действительно
нет, но ACCESS EXCLUSIVE берётся, и опасно не удержание лока, а ОЖИДАНИЕ его
выдачи — ждущий DDL встаёт в очередь перед новыми запросами приложения к той же
таблице. Так миграция 250 простояла 29 минут за чужой psql-сессией. Переписал
комментарий под то, что происходит на самом деле.

Refs #2899, #2752
2026-08-19 15:02:23 +05:00
2c852bd13b feat(tradein): позиция квартиры внутри когорты аналогов (перцентиль)
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
Макет показывает бейдж «Верх рынка» — место квартиры в распределении аналогов.
Поля не было ни в схеме, ни в расчёте.

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

В 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