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
Collaborator

Бэкенд-часть бейджа «Верх рынка». Поле считается, персистится и доходит до 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, ранг в подвыборке сравнивался с рангом в полной; поправка на
конечную популяцию учтена):

k ошибка ранга, пп ярлык «низ/рынок/верх» перепутан
5 13.4 26.4%
10 9.5
15 7.6
20 6.7
30 5.4 10.0%

Согласованный второй сигнал: шаг перцентиля 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 (бакеты премии, другая ось);
  • не гистограмма фронта — она строится по усечённым top-10 и на вопрос «где я» не отвечает.

Проверка

Тесты двусторонние. Против кода из main:

tests/test_2899_market_percentile.py   — падает на импорте (константы нет)
test_get_estimate_surfaces_market_percentile — FAILED
test_get_estimate_market_percentile_nullable — passed  ← контроль
  • pytest -k "estimat or estimate or percentile or idor"745 passed, 2 skipped
  • ruff check app/ tests/ — clean
  • миграция идемпотентна (IF NOT EXISTS), номер 267 без коллизий

Контроли зелёные с обеих сторон: null проходит как null (доказывает, что поле
необязательное и старые строки живы), лоты без цены не засчитываются в порог (иначе
пустышки обошли бы гейт n≥15).

Границы

UI не трогаю. Бейдж — это где он стоит, как называется и что показывать при None;
это продуктовое решение, а не следствие расчёта. Поле уже доступно фронту в ответе.
Заведу отдельную задачу, если решишь, что бейдж нужен — не хочу, чтобы «положение на
рынке» появилось на экране раньше, чем ты решишь, как оно называется.

Покрытие оценено по историческому хвосту. Все 1086 прод-строк просрочены
(max(created_at) = 2026-08-17, читаемых 0), живого трафика для замера нет.

Refs #2899

Бэкенд-часть бейджа «Верх рынка». Поле считается, персистится и доходит до 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, ранг в подвыборке сравнивался с рангом в полной; поправка на конечную популяцию учтена): | k | ошибка ранга, пп | ярлык «низ/рынок/верх» перепутан | |---|---|---| | 5 | 13.4 | **26.4%** | | 10 | 9.5 | — | | **15** | **7.6** | — | | 20 | 6.7 | — | | 30 | 5.4 | 10.0% | Согласованный второй сигнал: шаг перцентиля `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` (бакеты премии, другая ось); - не гистограмма фронта — она строится по усечённым top-10 и на вопрос «где я» не отвечает. ## Проверка Тесты двусторонние. Против кода из main: ``` tests/test_2899_market_percentile.py — падает на импорте (константы нет) test_get_estimate_surfaces_market_percentile — FAILED test_get_estimate_market_percentile_nullable — passed ← контроль ``` - [x] `pytest -k "estimat or estimate or percentile or idor"` — **745 passed**, 2 skipped - [x] `ruff check app/ tests/` — clean - [x] миграция идемпотентна (`IF NOT EXISTS`), номер 267 без коллизий Контроли зелёные с обеих сторон: `null` проходит как `null` (доказывает, что поле необязательное и старые строки живы), лоты без цены не засчитываются в порог (иначе пустышки обошли бы гейт n≥15). ## Границы **UI не трогаю.** Бейдж — это где он стоит, как называется и что показывать при `None`; это продуктовое решение, а не следствие расчёта. Поле уже доступно фронту в ответе. Заведу отдельную задачу, если решишь, что бейдж нужен — не хочу, чтобы «положение на рынке» появилось на экране раньше, чем ты решишь, как оно называется. **Покрытие оценено по историческому хвосту.** Все 1086 прод-строк просрочены (`max(created_at) = 2026-08-17`, читаемых 0), живого трафика для замера нет. Refs #2899
bot-backend added 1 commit 2026-08-19 09:11:07 +00:00
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
2c852bd13b
Макет показывает бейдж «Верх рынка» — место квартиры в распределении аналогов.
Поля не было ни в схеме, ни в расчёте.

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

В 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
Light1YT added 1 commit 2026-08-19 10:02:29 +00:00
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
9f17983689
Гейт `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
Author
Collaborator

Первый прогон покраснел — и поймал меня по делу.

::error file=…/267_trade_in_estimates_market_percentile.sql::блокирующий DDL без
lock_timeout. Добавь первой строкой после BEGIN: `SET LOCAL lock_timeout = '5s';`

Гейт check-migration-lock-timeout.py (#2752) прав. Хуже того, мой комментарий в
миграции был обманчив:

Блокировки не берёт (ADD COLUMN без DEFAULT в PG 11+ не переписывает таблицу)

Переписывания таблицы действительно нет — но ACCESS EXCLUSIVE берётся, и опасно не
удержание лока (миллисекунды), а ожидание его выдачи: ждущий DDL встаёт в очередь
перед новыми запросами приложения к той же таблице. Ровно так миграция 250 простояла
29 минут за чужой psql-сессией, и обычные SELECT встали за ней.

То есть я написал правдоподобное объяснение, которое отвечало не на тот вопрос. Гейт
существует именно против такого рассуждения.

Исправлено: BEGIN; SET LOCAL lock_timeout = '5s'; … COMMIT; и комментарий переписан под
то, что происходит на самом деле. Прогнал гейт локально:

selftest OK
✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: 17)
Первый прогон покраснел — и поймал меня по делу. ``` ::error file=…/267_trade_in_estimates_market_percentile.sql::блокирующий DDL без lock_timeout. Добавь первой строкой после BEGIN: `SET LOCAL lock_timeout = '5s';` ``` Гейт `check-migration-lock-timeout.py` (#2752) прав. Хуже того, мой комментарий в миграции был **обманчив**: > Блокировки не берёт (ADD COLUMN без DEFAULT в PG 11+ не переписывает таблицу) Переписывания таблицы действительно нет — но `ACCESS EXCLUSIVE` берётся, и опасно не удержание лока (миллисекунды), а **ожидание его выдачи**: ждущий DDL встаёт в очередь **перед** новыми запросами приложения к той же таблице. Ровно так миграция 250 простояла 29 минут за чужой psql-сессией, и обычные `SELECT` встали за ней. То есть я написал правдоподобное объяснение, которое отвечало не на тот вопрос. Гейт существует именно против такого рассуждения. Исправлено: `BEGIN; SET LOCAL lock_timeout = '5s'; … COMMIT;` и комментарий переписан под то, что происходит на самом деле. Прогнал гейт локально: ``` selftest OK ✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: 17) ```
bot-backend merged commit cf48e6d6c8 into main 2026-08-19 10:08:19 +00:00
bot-backend deleted branch feat/2899-market-percentile 2026-08-19 10:08:19 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2926
No description provided.