feat(tradein): позиция квартиры внутри когорты аналогов — перцентиль (#2899) #2926
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2926
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/2899-market-percentile"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Бэкенд-часть бейджа «Верх рынка». Поле считается, персистится и доходит до GET и PDF;
UI пока ничего не рисует — см. «Границы».
Где считается и почему именно там
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, ранг в подвыборке сравнивался с рангом в полной; поправка на
конечную популяцию учтена):
Согласованный второй сигнал: шаг перцентиля
100/nпротив ценового разрыва междусоседями по когорте. При n≈7 это 14.3 пп на разрыв 1.8% — один соседний лот двигает ярлык
на целую категорию; при n≈15 — 7.1 пп.
Переход 15→20 стоит ещё 10 пп покрытия и даёт ~1 пп точности — не окупается.
HEADLINE_LISTINGS_MIN_N = 5переиспользовать нельзя: он отвечает на другой вопрос —устойчива ли медиана. Она устойчива там, где ранг ещё пляшет.
Цена порога: по 1086 историческим прод-оценкам
n>=15покрывает 35.8% (n>=5дал бы76.2%, но с вдвое большей ошибкой). Ниже порога поле —
None, UI не рисует ничего:отдельную надпись «мало данных» не добавляю, эту роль уже несут
reliabilityиrelaxations.Персист обязателен
Пересчитать на GET нельзя — когорты в БД нет: в
analogsлежит top-10(
max(jsonb_array_length(analogs)) = 10приmax(n_analogs) = 294, у 599 из 1086 строксписок упёрся в потолок). Отсюда миграция 267 и проброс через оба SELECT'а — у GET и
PDF списки колонок разные и живут в разных функциях (PDF-SELECT уже отставал от GET на
created_at).getattr(row, "market_percentile", None)в конструкторах — тот же defensive-идиом, что уrelaxations/reliabilityстрокой ниже: строка без колонки деградирует в «позиции незнаем», а не роняет ответ
AttributeError'ом. Этот идиом добавлен не для красоты — безнего 8 существующих тестов с самодельными
SimpleNamespace-строками покраснели.Чем это НЕ является
location_index_pct— тот про РАЙОН против медианы города, живёт наGET /location-indexи в цену не идёт (location_index.py:35: «в цену не идёт»);low/median/high— это границы, а не позиция);price_premium_label(бакеты премии, другая ось);Проверка
Тесты двусторонние. Против кода из main:
pytest -k "estimat or estimate or percentile or idor"— 745 passed, 2 skippedruff check app/ tests/— cleanIF NOT EXISTS), номер 267 без коллизийКонтроли зелёные с обеих сторон:
nullпроходит какnull(доказывает, что поленеобязательное и старые строки живы), лоты без цены не засчитываются в порог (иначе
пустышки обошли бы гейт n≥15).
Границы
UI не трогаю. Бейдж — это где он стоит, как называется и что показывать при
None;это продуктовое решение, а не следствие расчёта. Поле уже доступно фронту в ответе.
Заведу отдельную задачу, если решишь, что бейдж нужен — не хочу, чтобы «положение на
рынке» появилось на экране раньше, чем ты решишь, как оно называется.
Покрытие оценено по историческому хвосту. Все 1086 прод-строк просрочены
(
max(created_at) = 2026-08-17, читаемых 0), живого трафика для замера нет.Refs #2899
Первый прогон покраснел — и поймал меня по делу.
Гейт
check-migration-lock-timeout.py(#2752) прав. Хуже того, мой комментарий вмиграции был обманчив:
Переписывания таблицы действительно нет — но
ACCESS EXCLUSIVEберётся, и опасно неудержание лока (миллисекунды), а ожидание его выдачи: ждущий DDL встаёт в очередь
перед новыми запросами приложения к той же таблице. Ровно так миграция 250 простояла
29 минут за чужой psql-сессией, и обычные
SELECTвстали за ней.То есть я написал правдоподобное объяснение, которое отвечало не на тот вопрос. Гейт
существует именно против такого рассуждения.
Исправлено:
BEGIN; SET LOCAL lock_timeout = '5s'; … COMMIT;и комментарий переписан подто, что происходит на самом деле. Прогнал гейт локально: