fix(ptica): границы правдоподобия у средней цены конкурента (#2464-D) #2863

Merged
bot-backend merged 1 commit from fix/2464-price-sanity-bounds into main 2026-08-13 11:57:04 +00:00
Collaborator

Что

obj_pricing в analyze_parcel считает AVG(price_per_m2_rub) по проекту и отдаёт
это в карточку конкурента и дальше в market_avg_price (среднее средних) — на экран.
Границ правдоподобия у среднего не было, хотя в двух соседних запросах по этой же
таблице
objective_lots они стоят: BETWEEN 30000 AND 600000.

Замер на проде 13.08 — через тот же путь, что и продукт

Считал не «по всей таблице», а как считает продакшен: physflat-дедуп (DISTINCT ON
по project_name, corpus_name, section, floor, lot_number) + маппинг на domrf_obj_id.

вне диапазона 30000..600000        204 лота из 2 279 827
    из них в замапленных проектах  118 (10 проектов, макс 1 249 194 ₽/м²)
    в незамапленных                 86 (16 проектов, макс 19 198 429 ₽/м², ЖК «Дебют»)

эффект правки на экране:
    проектов замапленных            308
    меняется среднее                  6
    из них больше чем на 5%           0   (худший — 2.4%)
    стало NULL                        0
    market_avg_price          138 056 → 138 008   (−48 ₽/м², 0.03%)

Эффект сегодня малый, и я это не прячу. Раньше в этой ветке я насчитал «26 проектов
из 890, худший в 24 раза» — это был замер по неверной популяции: все project_name
целиком, без дедупа и без маппинга, включая 573 проекта, которые до экрана не доходят.
Правильная цифра — 6 из 308 и 2.4%.

Зачем тогда чинить

Потому что ограничения сверху нет по построению. Среднее считается по проекту, поэтому
один лот держит группу: максимум в таблице — 19 198 429 ₽/м², и он вне экрана
исключительно потому, что «Дебют» пока не замаплен. Замаплено 308 имён из 881, список
пополняется. Одна строка маппинга — и это число уезжает в market_avg_price.

Как

  • AVG(...) FILTER (WHERE ... BETWEEN 30000 AND 600000)FILTER, а не WHERE на CTE:
    строки нужны целиком, иначе границы цены молча урезали бы units_sold /
    units_available, которые считают ВСЕ лоты.
  • lots_with_price теперь считает ту же популяцию, что кормит среднее (было: любые
    не-NULL цены) — иначе счётчик обещает выборку шире, чем участвовала.

Проверка

Живого прогона SQL по данным в наборе нет: интеграционный харнесс ПТИЦЫ
(tests/integration/test_analyze_parcels_sql.py) делает только EXPLAIN против прод-PG
(parse+plan, без scan). Поэтому проверка двухсторонняя, но на уровне текста запроса —
test_price_avg_has_sanity_bounds. Двусторонность проверена прогоном тех же утверждений
против main:

FAIL  avg bounded                 ← краснеет на main
FAIL  lots_with_price bounded     ← краснеет на main
PASS  units_sold unbounded        ← контроль, зелёный с обеих сторон
PASS  units_available unbounded   ← контроль, зелёный с обеих сторон

Числовая проверка — прод-A/B выше (старое и новое выражение на одних данных).

  • pytest tests/api/v1/test_analyze_competitors_status.py — 13 passed
  • ruff check — clean
  • прод-A/B на gendesign-postgres-1

Шум в диффе

Три хунка в тесте — не мои: pre-commit пинит ruff v0.7.4, а в backend/.venv
ruff 0.15.12, и они расходятся в форматировании assert-сообщений. Хук переформатировал
нетронутые строки. ruff format --check в CI намеренно не гейт (ci.yml:210), так что
это косметика — но пин стоит подтянуть отдельно.

Refs #2464

## Что `obj_pricing` в `analyze_parcel` считает `AVG(price_per_m2_rub)` **по проекту** и отдаёт это в карточку конкурента и дальше в `market_avg_price` (среднее средних) — на экран. Границ правдоподобия у среднего не было, хотя **в двух соседних запросах по этой же таблице** `objective_lots` они стоят: `BETWEEN 30000 AND 600000`. ## Замер на проде 13.08 — через тот же путь, что и продукт Считал не «по всей таблице», а как считает продакшен: physflat-дедуп (`DISTINCT ON` по `project_name, corpus_name, section, floor, lot_number`) + маппинг на `domrf_obj_id`. ``` вне диапазона 30000..600000 204 лота из 2 279 827 из них в замапленных проектах 118 (10 проектов, макс 1 249 194 ₽/м²) в незамапленных 86 (16 проектов, макс 19 198 429 ₽/м², ЖК «Дебют») эффект правки на экране: проектов замапленных 308 меняется среднее 6 из них больше чем на 5% 0 (худший — 2.4%) стало NULL 0 market_avg_price 138 056 → 138 008 (−48 ₽/м², 0.03%) ``` **Эффект сегодня малый, и я это не прячу.** Раньше в этой ветке я насчитал «26 проектов из 890, худший в 24 раза» — это был замер по неверной популяции: все `project_name` целиком, без дедупа и без маппинга, включая 573 проекта, которые до экрана не доходят. Правильная цифра — 6 из 308 и 2.4%. ## Зачем тогда чинить Потому что ограничения сверху нет по построению. Среднее считается по проекту, поэтому один лот держит группу: максимум в таблице — **19 198 429 ₽/м²**, и он вне экрана исключительно потому, что «Дебют» пока не замаплен. Замаплено 308 имён из 881, список пополняется. Одна строка маппинга — и это число уезжает в `market_avg_price`. ## Как - `AVG(...) FILTER (WHERE ... BETWEEN 30000 AND 600000)` — **FILTER, а не `WHERE` на CTE**: строки нужны целиком, иначе границы цены молча урезали бы `units_sold` / `units_available`, которые считают ВСЕ лоты. - `lots_with_price` теперь считает **ту же популяцию**, что кормит среднее (было: любые не-NULL цены) — иначе счётчик обещает выборку шире, чем участвовала. ## Проверка Живого прогона SQL по данным в наборе нет: интеграционный харнесс ПТИЦЫ (`tests/integration/test_analyze_parcels_sql.py`) делает **только EXPLAIN** против прод-PG (parse+plan, без scan). Поэтому проверка двухсторонняя, но на уровне текста запроса — `test_price_avg_has_sanity_bounds`. Двусторонность проверена прогоном тех же утверждений против `main`: ``` FAIL avg bounded ← краснеет на main FAIL lots_with_price bounded ← краснеет на main PASS units_sold unbounded ← контроль, зелёный с обеих сторон PASS units_available unbounded ← контроль, зелёный с обеих сторон ``` Числовая проверка — прод-A/B выше (старое и новое выражение на одних данных). - [x] `pytest tests/api/v1/test_analyze_competitors_status.py` — 13 passed - [x] `ruff check` — clean - [x] прод-A/B на `gendesign-postgres-1` ## Шум в диффе Три хунка в тесте — не мои: `pre-commit` пинит ruff **v0.7.4**, а в `backend/.venv` ruff **0.15.12**, и они расходятся в форматировании assert-сообщений. Хук переформатировал нетронутые строки. `ruff format --check` в CI намеренно не гейт (`ci.yml:210`), так что это косметика — но пин стоит подтянуть отдельно. Refs #2464
bot-backend added 1 commit 2026-08-13 11:37:54 +00:00
fix(ptica): границы правдоподобия у средней цены конкурента
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m4s
CI / backend-tests (pull_request) Successful in 16m26s
380d3a83a3
Среднее ₽/м² конкурента считается по проекту, и один лот держал группу
без ограничения сверху. Границы 30000..600000 уже стояли в двух соседних
запросах по objective_lots — в этом их не было.

Замер 13.08 через тот же путь (physflat-дедуп + маппинг на domrf_obj_id):
вне диапазона 204 лота из 2 279 827, из них 118 в 10 замапленных проектах.
Эффект сегодня малый — меняются 6 проектов из 308, худший на 2.4%,
market_avg_price 138 056 → 138 008, NULL не появляется. Ставим не ради
этих 48 ₽: максимум в таблице 19 198 429 ₽/м² (ЖК «Дебют»), и он вне
экрана только потому, что проект пока не замаплен (308 имён из 881).

FILTER, а не WHERE на CTE: строки нужны целиком, иначе границы цены
молча урезали бы units_sold / units_available, считающие ВСЕ лоты.
lots_with_price считает ту же популяцию, что и среднее.

Три хунка в тесте — не мои: pre-commit ruff-format (пин v0.7.4) переформатил
нетронутые строки, потому что отстал от ruff в venv.

Refs #2464
bot-backend merged commit 3fc406549e into main 2026-08-13 11:57:04 +00:00
bot-backend deleted branch fix/2464-price-sanity-bounds 2026-08-13 11:57:04 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
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#2863
No description provided.