tradein: остальные находки аудита области — C3 (квартальный индекс) + H1-H5 #2583

Open
opened 2026-07-31 14:03:49 +00:00 by lekss361 · 6 comments
Owner

Веерный аудит тихих дефектов (эпик #2576): 25 находок, 23 подтверждены состязательной проверкой, после дедупликации — 14 уникальных дефектов. Два критических вынесены отдельно: #2581 (якорь Tier A) и #2582 (гейт геокодера). Здесь — остальное.

CRITICAL

C3. Квартальный индекс нормирован на медиану Екатеринбурга, а фолбэк 1.0 трактует «уровень ЕКБ» как нейтраль

estimator.py:2759; MV data/sql/99a_quarter_price_index.sql; FDW 085_quarter_price_index_fdw.sql:52

city_median считается только по кварталам 66:41:% (ЕКБ, ≈110 883 ₽/м²), но индекс раздаётся всем кварталам 66:% (минимум по снапшоту 0.2771 — «периферия области»). В коррекции factor = target_index / avg_analog_index, а avg_analog_index = ... if weight_total > 0 else 1.0 — при нуле кадастров у аналогов знаменателем молча становится медиана Екатеринбурга.

Вне ЕКБ ноль кадастров — норма: listings.building_cadastral_number бэкфиллится из среза WHERE cad_num LIKE '66:41:%' (100_tradein_fdw_role.sql:37,43 — «Flat 8-col EKB-only slice»), нативно кадастр даёт только Циан, у Домклика 0%.

Вред: Нижний Тагил, честные локальные аналоги 62 тыс ₽/м² (≈3.4 млн) → target_qi≈0.5, avg=1.0factor=0.637 200 ₽/м² ≈ 2.05 млн. Множитель применяется к медиане, цене и обеим границам диапазона. Объяснение пользователю: «Учтена локация квартала (индекс цен квартала ×0.60)» — читается как методология.

Тихо на 100%: уверенность считается до этого блока; _manual_review не ловит (диапазон инвариантен к общему множителю); Guard-2 фейлится открыто (same_quarter_ratio = 0.0 → «двойного учёта нет» утверждается ровно там, где ничего не известно); Guard-4 срабатывает только при индексе > 2.0. Комментарий config.py:394-413 («нормальные индексы РФ в [0.6, 1.8]») прямо противоречит снапшоту с минимумом 0.2771.

HIGH

H1. _resolve_target_city → None → коридор ДКП теряет городской фильтр и ПОДНИМАЕТ цену. estimator.py:1301,1372,2834,2983. Тот же неполный список городов, что в #2582, но отдельная точка отказа. Город не распознан → коридор по d.address ILIKE '%Ленина%' по всей области, где доминирует ЕКБ. radius_floor = dkp_low × 0.8 при count ≥ 10 поднимает честную локальную медиану. «Реж, Ленина, 12» → 6-7 млн вместо ~2.2 млн с текстом «по реальным сделкам Росреестра». Не critical только потому, что уверенность там принудительно низкая.

H2. Коэффициент asking→sold: sold-сторона прибита к ЕКБ, asking-сторона не скоупится вообще. tasks/asking_to_sold_ratio.py:91,140 против :102,149; чтение estimator.py:359 (ключ без города). Сегодня один екатеринбургский коэффициент применяется к каждой оценке любого города. После включения расписания из миграции 179 дешёвый областной asking попадёт в знаменатель, sold останется ЕКБ → коэффициент вырастет (0.877 → ~0.95) → завышенная выкупная цена уедет всем, включая Екатеринбург. Зеркало уже пойманного перекоса 0.877→0.62 от миграции 177, симметричного гарда не поставили. Пересчёт honest_ratio:2945-2951 переписывает бейдж «−N% к рынку» под неверную цену — исчезает последняя перекрёстная проверка.

H3. Ночной бэкфилл координат бьёт в екатеринбургский реестр без гейта. tasks/backfill_listings_coords_geoportal.py:197. Берёт все листинги с lat IS NULL, парсит адрес (город выбрасывается) и зовёт _geoportal_house_match напрямую, минуя geocode() — то есть гейт _names_non_ekb_city не применяется вовсе. Тагильский лот получает координаты ЕКБ → тянет медиану чужих оценок, исчезает из выборки своего города, приклеивается к екатеринбургскому дому. Задача включена, без ограничений по источнику и лимита, окно 05:00-06:00 UTC — выигрывает гонку у областного geocode_missing_listings (06:00-09:00). Это активная порча данных каждую ночь.

H4. /sales-vs-listings: пары «сделка ↔ объявление» строятся по улице без города. api/v1/trade_in.py:1865 + 067_v_street_sales_vs_listings.sql:78-109. Хуже, чем отсутствие фильтра: LEFT JOIN ... ON listing_event_date BETWEEN ... — условие только по дате, корреляции адреса нет вообще. Пара = декартово произведение. «Нижний Тагил, Ленина, 5» → таблица «похожие квартиры по вашей улице» с ценами ЕКБ, колонка «торг» −40…−50%, футер «Показано 12 из 0 сделок» (соседний городской эндпоинт даёт 0). Соседний /street-deals:1717 городской скоуп получил — пропущенный сиблинг.

H5. ГАР-матч: фильтр ЕКБ только на стороне ГАР, UPDATE houses не ограничен ничем. services/gar_flats_loader.py:545-574. tradein_canon_addr срезает сегмент города для любого города → «г Нижний Тагил, ул Ленина, 5» и екатеринбургский «пр-кт Ленина, 5» дают один канон; DISTINCT ON (canon) ORDER BY flat_count DESC систематически выбирает самое крупное екатеринбургское здание. Тагильская пятиэтажка (60 квартир) получает gar_flat_count=166..400 и чужой gar_house_guid → доля продающихся падает в 3-6 раз, дом молча выпадает из выдачи. Каскад по чужому идентификатору: ЖКХ-загрузчик пишет чужие zhkh_flat_count/zhkh_year/zhkh_floors, а загрузчик капремонта матчит по COALESCE(gar_house_guid, ...) — фиктивный идентификатор приоритетнее корректного ФИАС и перетирает год постройки, материал стен, этажность. Тот же класс коллизии явно загарден в house_dedup_merge.py (250 м, комментарий дословно про «Ленина 5» в разных городах) и в matching/houses.py:38 (3 км) — незагарден остался только ГАР-матчер.

MEDIUM (кратко)

  • M1. Боевой интерфейс v2 нигде не читает confidence_explanation (а также analog_tier, address_precision, manual_review_recommended). Первоуральск: бэкенд честно пишет «расширили радиус до 2 км из-за нехватки данных», экран показывает только «достоверность: средняя», а карта продолжает рисовать окружность 1000 м — активно неверная визуальная подсказка. Старый интерфейс оговорку показывал.
  • M2. Подпись «только этот дом» / «История цен в этом доме» безусловна, хотя при <8 архивных лотов бэкенд подмешивает до 30 соседних домов в радиусе 300 м. На том же экране рядом написано «в радиусе 300 м» — две взаимоисключающие подписи.

Приоритет

  1. H3 — единственный, кто портит данные прямо сейчас, каждую ночь. Чинить первым, иначе накопим мусор, который потом чистить.
  2. #2581 (Tier A) — самая большая ошибка в цене и глушит областной фолбэк.
  3. #2582 + H1 — общий корень (список городов), логично чинить вместе.
  4. H2 — успеть до включения расписания из миграции 179, иначе перекос уедет и на Екатеринбург.
  5. C3, H4, H5 — после.
  6. M1/M2 — интерфейс, отдельным заходом.

Метод: 6 независимых лезвий → состязательная проверка каждой находки (по умолчанию считать неверной) → дедупликация. Полный отчёт с воспроизведением — в переписке аудита.

Веерный аудит тихих дефектов (эпик #2576): 25 находок, 23 подтверждены состязательной проверкой, после дедупликации — **14 уникальных дефектов**. Два критических вынесены отдельно: #2581 (якорь Tier A) и #2582 (гейт геокодера). Здесь — остальное. ## CRITICAL ### C3. Квартальный индекс нормирован на медиану Екатеринбурга, а фолбэк `1.0` трактует «уровень ЕКБ» как нейтраль `estimator.py:2759`; MV `data/sql/99a_quarter_price_index.sql`; FDW `085_quarter_price_index_fdw.sql:52` `city_median` считается только по кварталам `66:41:%` (ЕКБ, ≈110 883 ₽/м²), но индекс раздаётся всем кварталам `66:%` (минимум по снапшоту 0.2771 — «периферия области»). В коррекции `factor = target_index / avg_analog_index`, а `avg_analog_index = ... if weight_total > 0 else 1.0` — при нуле кадастров у аналогов знаменателем **молча становится медиана Екатеринбурга**. Вне ЕКБ ноль кадастров — норма: `listings.building_cadastral_number` бэкфиллится из среза `WHERE cad_num LIKE '66:41:%'` (`100_tradein_fdw_role.sql:37,43` — «Flat 8-col EKB-only slice»), нативно кадастр даёт только Циан, у Домклика 0%. **Вред:** Нижний Тагил, честные локальные аналоги 62 тыс ₽/м² (≈3.4 млн) → `target_qi≈0.5`, `avg=1.0` → `factor=0.6` → **37 200 ₽/м² ≈ 2.05 млн**. Множитель применяется к медиане, цене и обеим границам диапазона. Объяснение пользователю: «Учтена локация квартала (индекс цен квартала ×0.60)» — читается как методология. Тихо на 100%: уверенность считается до этого блока; `_manual_review` не ловит (диапазон инвариантен к общему множителю); Guard-2 фейлится открыто (`same_quarter_ratio = 0.0` → «двойного учёта нет» утверждается ровно там, где ничего не известно); Guard-4 срабатывает только при индексе > 2.0. Комментарий `config.py:394-413` («нормальные индексы РФ в [0.6, 1.8]») прямо противоречит снапшоту с минимумом 0.2771. ## HIGH **H1. `_resolve_target_city` → None → коридор ДКП теряет городской фильтр и ПОДНИМАЕТ цену.** `estimator.py:1301,1372,2834,2983`. Тот же неполный список городов, что в #2582, но отдельная точка отказа. Город не распознан → коридор по `d.address ILIKE '%Ленина%'` по всей области, где доминирует ЕКБ. `radius_floor = dkp_low × 0.8` при count ≥ 10 поднимает честную локальную медиану. «Реж, Ленина, 12» → **6-7 млн вместо ~2.2 млн** с текстом «по реальным сделкам Росреестра». Не critical только потому, что уверенность там принудительно низкая. **H2. Коэффициент asking→sold: sold-сторона прибита к ЕКБ, asking-сторона не скоупится вообще.** `tasks/asking_to_sold_ratio.py:91,140` против `:102,149`; чтение `estimator.py:359` (ключ без города). Сегодня один екатеринбургский коэффициент применяется к каждой оценке любого города. **После включения расписания из миграции 179** дешёвый областной asking попадёт в знаменатель, sold останется ЕКБ → коэффициент вырастет (0.877 → ~0.95) → завышенная выкупная цена уедет **всем, включая Екатеринбург**. Зеркало уже пойманного перекоса 0.877→0.62 от миграции 177, симметричного гарда не поставили. Пересчёт `honest_ratio:2945-2951` переписывает бейдж «−N% к рынку» под неверную цену — исчезает последняя перекрёстная проверка. **H3. Ночной бэкфилл координат бьёт в екатеринбургский реестр без гейта.** `tasks/backfill_listings_coords_geoportal.py:197`. Берёт все листинги с `lat IS NULL`, парсит адрес (город выбрасывается) и зовёт `_geoportal_house_match` **напрямую, минуя `geocode()`** — то есть гейт `_names_non_ekb_city` не применяется вовсе. Тагильский лот получает координаты ЕКБ → тянет медиану чужих оценок, исчезает из выборки своего города, приклеивается к екатеринбургскому дому. Задача включена, без ограничений по источнику и лимита, окно 05:00-06:00 UTC — **выигрывает гонку** у областного `geocode_missing_listings` (06:00-09:00). Это активная порча данных каждую ночь. **H4. `/sales-vs-listings`: пары «сделка ↔ объявление» строятся по улице без города.** `api/v1/trade_in.py:1865` + `067_v_street_sales_vs_listings.sql:78-109`. Хуже, чем отсутствие фильтра: `LEFT JOIN ... ON listing_event_date BETWEEN ...` — условие только по дате, корреляции адреса нет вообще. Пара = декартово произведение. «Нижний Тагил, Ленина, 5» → таблица «похожие квартиры по вашей улице» с ценами ЕКБ, колонка «торг» −40…−50%, футер «Показано 12 из 0 сделок» (соседний городской эндпоинт даёт 0). Соседний `/street-deals:1717` городской скоуп получил — пропущенный сиблинг. **H5. ГАР-матч: фильтр ЕКБ только на стороне ГАР, `UPDATE houses` не ограничен ничем.** `services/gar_flats_loader.py:545-574`. `tradein_canon_addr` срезает сегмент города для любого города → «г Нижний Тагил, ул Ленина, 5» и екатеринбургский «пр-кт Ленина, 5» дают один канон; `DISTINCT ON (canon) ORDER BY flat_count DESC` систематически выбирает **самое крупное екатеринбургское** здание. Тагильская пятиэтажка (60 квартир) получает `gar_flat_count=166..400` и чужой `gar_house_guid` → доля продающихся падает в 3-6 раз, дом молча выпадает из выдачи. **Каскад по чужому идентификатору:** ЖКХ-загрузчик пишет чужие `zhkh_flat_count`/`zhkh_year`/`zhkh_floors`, а загрузчик капремонта матчит по `COALESCE(gar_house_guid, ...)` — фиктивный идентификатор **приоритетнее корректного ФИАС** и перетирает год постройки, материал стен, этажность. Тот же класс коллизии **явно загарден** в `house_dedup_merge.py` (250 м, комментарий дословно про «Ленина 5» в разных городах) и в `matching/houses.py:38` (3 км) — незагарден остался только ГАР-матчер. ## MEDIUM (кратко) - **M1.** Боевой интерфейс v2 нигде не читает `confidence_explanation` (а также `analog_tier`, `address_precision`, `manual_review_recommended`). Первоуральск: бэкенд честно пишет «расширили радиус до 2 км из-за нехватки данных», экран показывает только «достоверность: средняя», а карта продолжает рисовать окружность 1000 м — активно неверная визуальная подсказка. Старый интерфейс оговорку показывал. - **M2.** Подпись «только этот дом» / «История цен в этом доме» безусловна, хотя при <8 архивных лотов бэкенд подмешивает до 30 соседних домов в радиусе 300 м. На том же экране рядом написано «в радиусе 300 м» — две взаимоисключающие подписи. ## Приоритет 1. **H3** — единственный, кто портит данные прямо сейчас, каждую ночь. Чинить первым, иначе накопим мусор, который потом чистить. 2. **#2581 (Tier A)** — самая большая ошибка в цене и глушит областной фолбэк. 3. **#2582 + H1** — общий корень (список городов), логично чинить вместе. 4. **H2** — успеть до включения расписания из миграции 179, иначе перекос уедет и на Екатеринбург. 5. **C3, H4, H5** — после. 6. **M1/M2** — интерфейс, отдельным заходом. Метод: 6 независимых лезвий → состязательная проверка каждой находки (по умолчанию считать неверной) → дедупликация. Полный отчёт с воспроизведением — в переписке аудита.
Author
Owner

H2 — перекос наступил, измерен на живых данных

Прогноз аудита («после включения расписания из миграции 179 областной asking попадёт в знаменатель») сбылся: областные развёртки заработали 12 июля, областные объявления уже в расчёте.

Подтверждаю код: tasks/asking_to_sold_ratio.py — сторона сделок ограничена (deal_side/deal_global: AND city ILIKE :asking_city), сторона объявлений (ask_side/ask_global) не ограничена по городу вообще.

Состав стороны объявлений сейчас

город                 объявлений   медиана ₽/м²
(город неизвестен)         8206        138 254
Екатеринбург               2114        150 048
Каменск-Уральский           286         69 746
Нижний Тагил                269         77 656
Верхняя Пышма               245        148 861
Первоуральск                213         78 873
Серов                       159         63 414

1172 областных лота из ~11 500 — около 10% выборки, с медианами вдвое ниже екатеринбургских (кроме Пышмы — она город-спутник и стоит как ЕКБ).

Насколько завышен коэффициент

ratio = sold_median / ask_median, областные тянут знаменатель вниз → коэффициент вверх:

комнат ask сейчас ask без известных областных коэффициент завышен
0 (студии) 182 879 182 825 ~0%
1 145 340 153 000 +5,3%
2 129 237 133 452 +3,3%
3 124 361 127 748 +2,7%
4 127 165 130 283 +2,5%

Коэффициент умножается на цену объявления для получения выкупной, значит выкупные цены завышены на 2,5-5,3% — по всем оценкам, включая екатеринбургские. Направление ошибки неприятное: предлагаем больше, чем следует.

Текущие значения в asking_to_sold_ratios (пересчёт 2026-08-02 06:31): global 0.888, однушки 0.8169, двушки 0.8776, трёшки 0.9712, четырёшки 1.0503. Аудит предсказывал рост 0.877 → ~0.95 для двушек; фактически пока +3,3%, потому что доля области ещё невелика — но она растёт с каждой ночной развёрткой.

Чем чинить

Появился инструмент, которого не было на момент аудита: колонка listings.city (#2598, бэкфилл #2606). Но она заполнена только у Авито — у Циана, Домклика и Яндекса пусто, а это как раз те 8206 строк «город неизвестен», в основном екатеринбургские.

Поэтому прямое city ILIKE :asking_city неверно — выбросит почти всю выборку. Правильный предикат для текущей полноты данных:

AND (city IS NULL OR city ILIKE :asking_city)

То есть исключаем известно-областные, оставляем известно-екатеринбургские и пока-неизвестные. По мере роста покрытия колонки предикат сам становится строже, без новых правок.

Симметрия с deal_side при этом восстанавливается: обе стороны про один рынок.

Побочно

Четырёхкомнатные дают ratio = 1.05 — сделки дороже объявлений. Похоже на шум малой выборки (1476 сделок против 1144 объявлений), но после фикса стоит перепроверить: если коэффициент выше единицы сохранится, это отдельный вопрос к методике, а не к городам.

Запускаю исправление.

## H2 — перекос наступил, измерен на живых данных Прогноз аудита («после включения расписания из миграции 179 областной asking попадёт в знаменатель») **сбылся**: областные развёртки заработали 12 июля, областные объявления уже в расчёте. Подтверждаю код: `tasks/asking_to_sold_ratio.py` — сторона сделок ограничена (`deal_side`/`deal_global`: `AND city ILIKE :asking_city`), сторона объявлений (`ask_side`/`ask_global`) **не ограничена по городу вообще**. ### Состав стороны объявлений сейчас ``` город объявлений медиана ₽/м² (город неизвестен) 8206 138 254 Екатеринбург 2114 150 048 Каменск-Уральский 286 69 746 Нижний Тагил 269 77 656 Верхняя Пышма 245 148 861 Первоуральск 213 78 873 Серов 159 63 414 ``` 1172 областных лота из ~11 500 — около 10% выборки, с медианами вдвое ниже екатеринбургских (кроме Пышмы — она город-спутник и стоит как ЕКБ). ### Насколько завышен коэффициент `ratio = sold_median / ask_median`, областные тянут знаменатель вниз → коэффициент вверх: | комнат | ask сейчас | ask без известных областных | коэффициент завышен | |---|---|---|---| | 0 (студии) | 182 879 | 182 825 | ~0% | | 1 | 145 340 | 153 000 | **+5,3%** | | 2 | 129 237 | 133 452 | +3,3% | | 3 | 124 361 | 127 748 | +2,7% | | 4 | 127 165 | 130 283 | +2,5% | Коэффициент умножается на цену объявления для получения выкупной, значит **выкупные цены завышены на 2,5-5,3%** — по всем оценкам, включая екатеринбургские. Направление ошибки неприятное: предлагаем больше, чем следует. Текущие значения в `asking_to_sold_ratios` (пересчёт 2026-08-02 06:31): global 0.888, однушки 0.8169, двушки 0.8776, трёшки 0.9712, четырёшки 1.0503. Аудит предсказывал рост 0.877 → ~0.95 для двушек; фактически пока +3,3%, потому что доля области ещё невелика — **но она растёт с каждой ночной развёрткой**. ### Чем чинить Появился инструмент, которого не было на момент аудита: колонка `listings.city` (#2598, бэкфилл #2606). Но она заполнена только у Авито — у Циана, Домклика и Яндекса пусто, а это как раз те 8206 строк «город неизвестен», в основном екатеринбургские. Поэтому прямое `city ILIKE :asking_city` неверно — выбросит почти всю выборку. Правильный предикат для текущей полноты данных: ```sql AND (city IS NULL OR city ILIKE :asking_city) ``` То есть исключаем **известно-областные**, оставляем известно-екатеринбургские и пока-неизвестные. По мере роста покрытия колонки предикат сам становится строже, без новых правок. Симметрия с `deal_side` при этом восстанавливается: обе стороны про один рынок. ### Побочно Четырёхкомнатные дают `ratio = 1.05` — сделки дороже объявлений. Похоже на шум малой выборки (1476 сделок против 1144 объявлений), но после фикса стоит перепроверить: если коэффициент выше единицы сохранится, это отдельный вопрос к методике, а не к городам. Запускаю исправление.
Author
Owner

H2 — исправлено частично, PR #2617 в проде (e484b5cc)

Скоуп городов на стороне объявлений восстановлен предикатом (city IS NULL OR city ILIKE :asking_city). Деплой прошёл, код проверен в обоих контейнерах.

Наивный вариант был бы катастрофой — цифра, ради которой стоило проверять

вариант строк ask_median ratio
было (без скоупа) 11 508 136 547 0.9089
стало (с терпимостью к NULL) 10 350 140 794 0.8815
наивный (city ILIKE без IS NULL) 2 132 150 802 0.8230

Наивный фикс выбросил бы 80% выборки — и это невозможно заметить по значению коэффициента: 0.823 выглядит правдоподобнее, чем 0.909. Тест на это есть и ловит мутацию.

Остаточный перекос — измерен по координатам, а не по адресу

Ревьюер сделал это лучше, чем я предлагал: адрес не годится (у ~70% строк Циана города в адресе нет вовсе, только районы вида «р-н Верх-Исетский, ул. Папанина, 3»). Мерил по расстоянию от центра Екатеринбурга среди 8 218 строк с пустым городом:

полоса строк медиана ₽/м²
≤20 км 8 004 139 810
20-40 км 5 109 285
40-100 км 10 81 920
>100 км 197 83 488

То есть в знаменателе осталось ~210 областных строк (почти все — Нижний Тагил). Остаточное завышение коэффициента: 0.6-1.2% (однушки 1.20%, двушки 1.11%, трёшки 0.00%).

Итог: фикс снимает 80-85% ошибки H2. Находка закрыта частично. Полное закрытие наступит само по мере заполнения listings.city — колонка уже проставляется на новых прогонах (за 3 дня: avito 1722/1722, cian 939/953, yandex 3002/3704, domklik 39/371), а ON CONFLICT обновляет её при повторном обходе. Точка сходимости посчитана: 10 140 строк, коэффициент ≈0.876, то есть ещё −0.65% без правок кода.

⚠️ Поправка к эффекту на цены

estimator.py:2911-2919 клампит коэффициент выше единицы до 1.0 (настройка ESTIMATE_EXPECTED_SOLD_LE_ASKING, по умолчанию включена). Поэтому фактический эффект на выкупную цену не совпадает с таблицей коэффициентов:

бакет эффект на цену
студии +0.28% (растут, а не «~0»)
1-комн −4.88%
2-комн −3.11%
3-комн −2.50%
4+ комн 0% — оба значения клампятся до 1.0
global fallback −3.01%

Пересчёт произойдёт сам по расписанию, ближайший — 2026-08-03 06:43 UTC.

Побочные находки

Коэффициент >1 у 4+ комнат оказался не шумом, а методикой — вынесено в #2620: новостроечный гард стоит только на стороне объявлений (в deals маркера сегмента нет вовсе), а deals.rooms обрезан на четырёх, тогда как в бакет 4 у объявлений сваливаются 110 квартир с 5-10 комнатами. Сравниваются разные совокупности.

Единый екатеринбургский коэффициент применяется ко всем городам (#647). Фактические по городам: ЕКБ 0.823, Тагил 0.822, Первоуральск 0.847, Верхняя Пышма 0.692, Каменск-Уральский 0.921, Серов 0.920. Для Каменска и Серова фикс уводит применяемый коэффициент чуть дальше от истины, для остальных — ближе; нетто выигрыш очевиден, поскольку ЕКБ это подавляющее большинство оценок.

listings.city из write-only колонки стала money-critical. До этого PR её никто не читал; теперь она напрямую двигает цены. Гард на синхронизацию списка городов есть (test_city_sweep.py, сверка множеств с CITY_LOCATIONS), но любая будущая ошибка разметки теперь бьёт по деньгам.

Статус находок аудита

  • C3 (квартальный индекс) — не начато, следующее в очереди
  • H1 (_resolve_target_city → коридор ДКП) — не начато
  • H2 — частично закрыто (80-85%), остаток сходится сам
  • H3 — закрыто ранее
  • H4 (/sales-vs-listings без города) — не начато
  • H5 (ГАР-матч без городского гейта) — не начато
  • M1/M2 (интерфейс) — не начато
## H2 — исправлено частично, PR #2617 в проде (`e484b5cc`) Скоуп городов на стороне объявлений восстановлен предикатом `(city IS NULL OR city ILIKE :asking_city)`. Деплой прошёл, код проверен в обоих контейнерах. ### Наивный вариант был бы катастрофой — цифра, ради которой стоило проверять | вариант | строк | ask_median | ratio | |---|---|---|---| | было (без скоупа) | 11 508 | 136 547 | 0.9089 | | стало (с терпимостью к NULL) | 10 350 | 140 794 | 0.8815 | | **наивный** (`city ILIKE` без `IS NULL`) | **2 132** | 150 802 | **0.8230** | Наивный фикс выбросил бы 80% выборки — и это **невозможно заметить по значению коэффициента**: 0.823 выглядит правдоподобнее, чем 0.909. Тест на это есть и ловит мутацию. ### Остаточный перекос — измерен по координатам, а не по адресу Ревьюер сделал это лучше, чем я предлагал: адрес не годится (у ~70% строк Циана города в адресе нет вовсе, только районы вида «р-н Верх-Исетский, ул. Папанина, 3»). Мерил по расстоянию от центра Екатеринбурга среди 8 218 строк с пустым городом: | полоса | строк | медиана ₽/м² | |---|---|---| | ≤20 км | 8 004 | 139 810 | | 20-40 км | 5 | 109 285 | | 40-100 км | 10 | 81 920 | | >100 км | **197** | 83 488 | То есть в знаменателе осталось ~210 областных строк (почти все — Нижний Тагил). Остаточное завышение коэффициента: **0.6-1.2%** (однушки 1.20%, двушки 1.11%, трёшки 0.00%). **Итог: фикс снимает 80-85% ошибки H2. Находка закрыта частично.** Полное закрытие наступит само по мере заполнения `listings.city` — колонка уже проставляется на новых прогонах (за 3 дня: avito 1722/1722, cian 939/953, yandex 3002/3704, domklik 39/371), а `ON CONFLICT` обновляет её при повторном обходе. Точка сходимости посчитана: 10 140 строк, коэффициент ≈0.876, то есть ещё −0.65% без правок кода. ### ⚠️ Поправка к эффекту на цены `estimator.py:2911-2919` клампит коэффициент выше единицы до `1.0` (настройка `ESTIMATE_EXPECTED_SOLD_LE_ASKING`, по умолчанию включена). Поэтому фактический эффект на выкупную цену **не совпадает** с таблицей коэффициентов: | бакет | эффект на цену | |---|---| | студии | **+0.28%** (растут, а не «~0») | | 1-комн | −4.88% | | 2-комн | −3.11% | | 3-комн | −2.50% | | **4+ комн** | **0%** — оба значения клампятся до 1.0 | | global fallback | −3.01% | Пересчёт произойдёт сам по расписанию, ближайший — **2026-08-03 06:43 UTC**. ### Побочные находки **Коэффициент >1 у 4+ комнат оказался не шумом, а методикой** — вынесено в #2620: новостроечный гард стоит только на стороне объявлений (в `deals` маркера сегмента нет вовсе), а `deals.rooms` обрезан на четырёх, тогда как в бакет 4 у объявлений сваливаются 110 квартир с 5-10 комнатами. Сравниваются разные совокупности. **Единый екатеринбургский коэффициент применяется ко всем городам** (#647). Фактические по городам: ЕКБ 0.823, Тагил 0.822, Первоуральск 0.847, Верхняя Пышма 0.692, Каменск-Уральский 0.921, Серов 0.920. Для Каменска и Серова фикс уводит применяемый коэффициент чуть дальше от истины, для остальных — ближе; нетто выигрыш очевиден, поскольку ЕКБ это подавляющее большинство оценок. **`listings.city` из write-only колонки стала money-critical.** До этого PR её никто не читал; теперь она напрямую двигает цены. Гард на синхронизацию списка городов есть (`test_city_sweep.py`, сверка множеств с `CITY_LOCATIONS`), но любая будущая ошибка разметки теперь бьёт по деньгам. ### Статус находок аудита - C3 (квартальный индекс) — не начато, следующее в очереди - H1 (`_resolve_target_city` → коридор ДКП) — не начато - **H2 — частично закрыто (80-85%), остаток сходится сам** - H3 — закрыто ранее - H4 (`/sales-vs-listings` без города) — не начато - H5 (ГАР-матч без городского гейта) — не начато - M1/M2 (интерфейс) — не начато
Author
Owner

Working on this in PR #2627 (H4: городской фильтр в /sales-vs-listings).

Working on this in PR #2627 (H4: городской фильтр в /sales-vs-listings).
Author
Owner

H4 закрыт — проверено на проде после деплоя

PR #2627 в проде (c1b40752), миграция 205 применена, сигнатура функции получила седьмой параметр p_target_city text DEFAULT NULL.

Замер прямо на живой функции, тагильская улица Ленина (2 комн, 44,3 м²):

выборка пар с объявлением медианный «торг»
без городского фильтра 352 244 −64%
с фильтром «Нижний Тагил» 8 6 −13%
контроль: Екатеринбург 14 7 +11%

То есть тагильскому пользователю виджет показывал «медианный торг минус шестьдесят четыре процента», собрав 352 пары из разных городов — цены Екатеринбурга против сделок Тагила. Контрольная екатеринбургская выборка не изменилась вовсе.

Решения автора, которые стоит зафиксировать:

  • Город берётся из _resolve_target_city(address) — тот же резолвер, что в соседнем /street-deals. Для адресов вне словаря вернёт None и фильтр не применится. Это осознанный компромисс: альтернатива (пустой результат) создала бы расхождение между двумя виджетами на одной странице для одного адреса. Зависимость от H1 сохраняется — при её починке этот эндпоинт станет точнее автоматически.
  • Стороны фильтруются с разной строгостью: deals.city заполнена на 100%, поэтому строго; listings.city заполнена частично (avito 63%, yandex 19%, cian 4,6%), поэтому терпимо к NULL — иначе выбросили бы почти все объявления кроме Авито. Тот же паттерн, что принят в #2617 для коэффициента выкупа.
  • Старая шестиаргументная сигнатура явно удалена перед созданием новой: CREATE OR REPLACE с добавленным параметром создал бы перегрузку, а не замену, и вызовы могли бы уйти в старую версию.

План запроса не изменился, стоимость 2404 → 2383.


Сводка по остальным находкам аудита

Закрыто:

  • H2 — частично (#2617, 80-85%, остаток сходится сам)
  • H3 — закрыто ранее
  • H4 — закрыто (#2627), подтверждено на проде

В работе: порог достаточности аналогов — корень сразу двух критических находок сквозной проверки (ноль рублей по Каменску при живом рынке в 25 лотов; разброс 1,66× по Серову в зависимости от улицы).

Не начато:

  • C3 (квартальный индекс на медиане ЕКБ) — механика подтверждена, но блок сейчас обезоружен: не выполняется с ~5 июля из-за прав на FDW, ноль применений в 238 июльских оценках. То есть мина не взведена, но и корректировка не работает.
  • H1 (_resolve_target_city → None → коридор ДКП без города) — от неё зависит точность H4.
  • H5 (ГАР-матч без городского гейта) — понижено при проверке: gar_flat_count в оценке квартиры не участвует, питает только аналитический эндпоинт «доля квартир дома в продаже».
  • M1/M2 (интерфейс не читает confidence_explanation, подпись «только этот дом» безусловна) — данные на бэкенде есть, дело во фронте.
  • Единый коэффициент выкупа на все города (#647) — фактические по городам: ЕКБ 0.823, Тагил 0.822, Первоуральск 0.847, В.Пышма 0.692, Каменск 0.921, Серов 0.920.

Найдено сверх аудита и заведено отдельно:

  • #2625 — капча Циана и пустые выдачи Яндекса засчитываются как успешный прогон (24 из 33 у Яндекса, 17 из 35 у Циана за 2 недели)
  • #2620 — коэффициент выкупа больше единицы у 4+ комнат: несопоставимые совокупности
  • Вторичка Циана по области не собиралась вовсе (#2623, исправлено)
  • Городская метка ставилась без проверки координат (#2626, исправлено)
## H4 закрыт — проверено на проде после деплоя PR #2627 в проде (`c1b40752`), миграция 205 применена, сигнатура функции получила седьмой параметр `p_target_city text DEFAULT NULL`. Замер прямо на живой функции, тагильская улица Ленина (2 комн, 44,3 м²): | выборка | пар | с объявлением | медианный «торг» | |---|---|---|---| | без городского фильтра | **352** | 244 | **−64%** | | с фильтром «Нижний Тагил» | 8 | 6 | **−13%** | | контроль: Екатеринбург | 14 | 7 | **+11%** | То есть тагильскому пользователю виджет показывал «медианный торг минус шестьдесят четыре процента», собрав 352 пары из разных городов — цены Екатеринбурга против сделок Тагила. Контрольная екатеринбургская выборка не изменилась вовсе. Решения автора, которые стоит зафиксировать: - **Город берётся из `_resolve_target_city(address)`** — тот же резолвер, что в соседнем `/street-deals`. Для адресов вне словаря вернёт `None` и фильтр не применится. Это осознанный компромисс: альтернатива (пустой результат) создала бы расхождение между двумя виджетами на одной странице для одного адреса. Зависимость от H1 сохраняется — при её починке этот эндпоинт станет точнее автоматически. - **Стороны фильтруются с разной строгостью**: `deals.city` заполнена на 100%, поэтому строго; `listings.city` заполнена частично (avito 63%, yandex 19%, cian 4,6%), поэтому терпимо к `NULL` — иначе выбросили бы почти все объявления кроме Авито. Тот же паттерн, что принят в #2617 для коэффициента выкупа. - **Старая шестиаргументная сигнатура явно удалена** перед созданием новой: `CREATE OR REPLACE` с добавленным параметром создал бы перегрузку, а не замену, и вызовы могли бы уйти в старую версию. План запроса не изменился, стоимость 2404 → 2383. --- ## Сводка по остальным находкам аудита **Закрыто:** - H2 — частично (#2617, 80-85%, остаток сходится сам) - H3 — закрыто ранее - **H4 — закрыто (#2627), подтверждено на проде** **В работе:** порог достаточности аналогов — корень сразу двух критических находок сквозной проверки (ноль рублей по Каменску при живом рынке в 25 лотов; разброс 1,66× по Серову в зависимости от улицы). **Не начато:** - **C3** (квартальный индекс на медиане ЕКБ) — механика подтверждена, но блок **сейчас обезоружен**: не выполняется с ~5 июля из-за прав на FDW, ноль применений в 238 июльских оценках. То есть мина не взведена, но и корректировка не работает. - **H1** (`_resolve_target_city` → None → коридор ДКП без города) — от неё зависит точность H4. - **H5** (ГАР-матч без городского гейта) — понижено при проверке: `gar_flat_count` в оценке квартиры не участвует, питает только аналитический эндпоинт «доля квартир дома в продаже». - **M1/M2** (интерфейс не читает `confidence_explanation`, подпись «только этот дом» безусловна) — данные на бэкенде есть, дело во фронте. - **Единый коэффициент выкупа на все города** (#647) — фактические по городам: ЕКБ 0.823, Тагил 0.822, Первоуральск 0.847, В.Пышма 0.692, Каменск 0.921, Серов 0.920. **Найдено сверх аудита и заведено отдельно:** - #2625 — капча Циана и пустые выдачи Яндекса засчитываются как успешный прогон (24 из 33 у Яндекса, 17 из 35 у Циана за 2 недели) - #2620 — коэффициент выкупа больше единицы у 4+ комнат: несопоставимые совокупности - Вторичка Циана по области не собиралась вовсе (#2623, исправлено) - Городская метка ставилась без проверки координат (#2626, исправлено)
Collaborator

Разобрал порог достаточности аналогов — тот, что здесь помечен «в работе» как корень двух критических находок сквозной проверки. Плюс две поправки к моим же прошлым комментариям.

Порог — это не одно число, а цепочка из трёх

Лестница подбора: 1 км с когортой года (нужно ≥5) → 1 км без когорты → 2 км → 2 км с площадью ±25%. Затем три порога подряд: HEADLINE_LISTINGS_MIN_N=5 на радиусный пул (при 0<n<5 весь агрегат обнуляется), затем якорь того же дома с estimate_sb_min_comps=4, затем ДКП-коридор Росреестра с DEALS_HEADLINE_FALLBACK_MIN_N=3. Не прошло ничего — median=0 и честный insufficient_data. Отказа фильтров по комнатности и сегменту в лестнице нет.

Каменск-Уральский: протухшим инвентарём НЕ объясняется

Я предполагал, что ноль рублей даёт мёртвый инвентарь. Проверил — нет. В окружности 10 км вокруг центра 472 активных лота с ценой и геометрией, из них свежих 472, то есть 100% (avito 67, cian 264, yandex 141 — областные развёртки отработали недавно).

Отказ даёт сам порог. Симуляция полной лестницы на живых данных, каждый вторичный лот как subject: финальное n<5 у 72 из 453 (15.9%), средний n=12.7. На одном километре без лестницы — 153 из 453 (33.8%). Причём это оптимистичная верхняя граница: симуляция не применяет когортный фильтр по году, geo_precision, ограничение в 5 лотов на адрес.

Вывод: порог 5 сам по себе не чинится — чинится то, что под ним. Мерить надо покрытие ДКП-фолбэка по Каменску (сколько комбинаций комнатность×площадь×улица дают меньше 3 сделок за 12 месяцев) и при необходимости ослаблять именно фолбэк — расширять окно с 12 месяцев, брать бакет по площади вместо ±15%, — а не опускать порог обратно.

Серов: порог это уже починил

Разброс, который здесь назван 1.66×, закрыт PR #2629 — в проде с 2026-08-02 12:41 UTC, проверено grep-ом по коду в контейнере. Замер автора: 1.33× → 1.11×. Расхождение чисел объясняется тем, что комментарий выше написан за 40 минут до мержа.

Гипотеза про протухшие подтвердилась в другом месте

Для радиусного пула она опровергнута — там стоит scraped_at > NOW() - 14 дней, и на проде свежий scraped_at при протухшем last_seen_at не встречается ни разу (0 из 37 497). Зато подтверждена для якоря: _fetch_anchor_comps фильтрует только is_active и про свежесть не знает вообще, а якорь при срабатывании переписывает и цену, и n_analogs. 268 из 553 адресных групп набирают порог 4 только за счёт протухших, 25 групп — вообще без единого свежего аналога. Вынес отдельно в #2656, потому что это прямые деньги.

Поправка: ссылка на #647 неверна

В моих комментариях выше единый коэффициент выкупа по городам привязан к #647. Проверил: #647 — это «District/quarter price-index, локационная премия в estimator», закрыт 31.05 как superseded PR #858/#863, то есть про квартальный индекс (находку C3), а не про коэффициент выкупа.

Отдельной задачи на per-city коэффициент нет вовсе — тема живёт только в комментарии к частично закрытой H2 и потеряется. Измеренные фактические коэффициенты (ЕКБ 0.823, Тагил 0.822, Первоуральск 0.847, В. Пышма 0.692, Каменск 0.921, Серов 0.920) стоит вынести в свою задачу со ссылкой на #2617.

Разобрал порог достаточности аналогов — тот, что здесь помечен «в работе» как корень двух критических находок сквозной проверки. Плюс две поправки к моим же прошлым комментариям. ## Порог — это не одно число, а цепочка из трёх Лестница подбора: 1 км с когортой года (нужно ≥5) → 1 км без когорты → 2 км → 2 км с площадью ±25%. Затем три порога подряд: `HEADLINE_LISTINGS_MIN_N=5` на радиусный пул (при `0<n<5` весь агрегат обнуляется), затем якорь того же дома с `estimate_sb_min_comps=4`, затем ДКП-коридор Росреестра с `DEALS_HEADLINE_FALLBACK_MIN_N=3`. Не прошло ничего — median=0 и честный `insufficient_data`. Отказа фильтров по комнатности и сегменту в лестнице нет. ## Каменск-Уральский: протухшим инвентарём НЕ объясняется Я предполагал, что ноль рублей даёт мёртвый инвентарь. Проверил — нет. В окружности 10 км вокруг центра **472 активных лота с ценой и геометрией, из них свежих 472, то есть 100%** (avito 67, cian 264, yandex 141 — областные развёртки отработали недавно). Отказ даёт сам порог. Симуляция полной лестницы на живых данных, каждый вторичный лот как subject: финальное `n<5` у **72 из 453 (15.9%)**, средний n=12.7. На одном километре без лестницы — 153 из 453 (33.8%). Причём это **оптимистичная верхняя граница**: симуляция не применяет когортный фильтр по году, `geo_precision`, ограничение в 5 лотов на адрес. Вывод: порог 5 сам по себе не чинится — чинится то, что под ним. Мерить надо покрытие ДКП-фолбэка по Каменску (сколько комбинаций комнатность×площадь×улица дают меньше 3 сделок за 12 месяцев) и при необходимости ослаблять именно фолбэк — расширять окно с 12 месяцев, брать бакет по площади вместо ±15%, — а не опускать порог обратно. ## Серов: порог это уже починил Разброс, который здесь назван 1.66×, закрыт PR #2629 — в проде с 2026-08-02 12:41 UTC, проверено grep-ом по коду в контейнере. Замер автора: 1.33× → 1.11×. Расхождение чисел объясняется тем, что комментарий выше написан за 40 минут до мержа. ## Гипотеза про протухшие подтвердилась в другом месте Для радиусного пула она **опровергнута** — там стоит `scraped_at > NOW() - 14 дней`, и на проде свежий `scraped_at` при протухшем `last_seen_at` не встречается ни разу (0 из 37 497). Зато **подтверждена для якоря**: `_fetch_anchor_comps` фильтрует только `is_active` и про свежесть не знает вообще, а якорь при срабатывании переписывает и цену, и `n_analogs`. 268 из 553 адресных групп набирают порог 4 **только** за счёт протухших, 25 групп — вообще без единого свежего аналога. Вынес отдельно в #2656, потому что это прямые деньги. ## Поправка: ссылка на #647 неверна В моих комментариях выше единый коэффициент выкупа по городам привязан к #647. Проверил: #647 — это «District/quarter price-index, локационная премия в estimator», закрыт 31.05 как superseded PR #858/#863, то есть про квартальный индекс (находку C3), а не про коэффициент выкупа. Отдельной задачи на per-city коэффициент **нет вовсе** — тема живёт только в комментарии к частично закрытой H2 и потеряется. Измеренные фактические коэффициенты (ЕКБ 0.823, Тагил 0.822, Первоуральск 0.847, В. Пышма 0.692, Каменск 0.921, Серов 0.920) стоит вынести в свою задачу со ссылкой на #2617.
Collaborator

C3 — в PR #2836, оставлен открытым (правка двигает видимую цену).

Коротко: постановка на коде верна, но сегодня эффект нулевой — фолбэк 1.0 не срабатывал на проде ни разу (15 применений индекса за всё время, все ЕКБ, у каждого были проиндексированные аналоги). Область до блока не доходит: тот же отсутствующий кадастр, что обнуляет weight_total, не даёт резолвиться и target_quarter. Задача «на вырост».

Мина при этом взведена: 92 дома вне ЕКБ уже имеют кадастр + квартал в индексе и ноль проиндексированных аналогов рядом → медианный срез цены −21.3%, хвост −40% (13 домов на кламп-полу).

Опровержения к комментариям выше:

  • «Блок обезоружен, ноль применений в июльских оценках» — устарело. #2641 вернул грант 04.08, блок отработал 6 раз с 5 по 12 августа, последний сегодня в 13:00 UTC.
  • «Данные идут через MV и FDW, правка только в коде может не дать эффекта» — для C3 неверно. Второй 1.0 (basis=city_fallback в 99a) и district-фолбэк имеют n_deals 3–4, эстиматор требует n_deals >= 10. На проде до него доходят только строки basis=quarter (district 0 из 281, city_fallback 0 из 1). MV/FDW трогать не нужно.

Проверка направления на сделках Росреестра (правка поднимает цену, поэтому проверялась отдельно): ошибка против реальных ДКП падает с −32…−40% до 0…+14% во всех шести городах.

H1-H5 не трогал.

C3 — в PR #2836, **оставлен открытым** (правка двигает видимую цену). Коротко: постановка на коде верна, но **сегодня эффект нулевой** — фолбэк `1.0` не срабатывал на проде ни разу (15 применений индекса за всё время, все ЕКБ, у каждого были проиндексированные аналоги). Область до блока не доходит: тот же отсутствующий кадастр, что обнуляет `weight_total`, не даёт резолвиться и `target_quarter`. Задача «на вырост». Мина при этом взведена: 92 дома вне ЕКБ уже имеют кадастр + квартал в индексе и ноль проиндексированных аналогов рядом → медианный срез цены −21.3%, хвост −40% (13 домов на кламп-полу). Опровержения к комментариям выше: - «Блок обезоружен, ноль применений в июльских оценках» — устарело. #2641 вернул грант 04.08, блок отработал 6 раз с 5 по 12 августа, последний сегодня в 13:00 UTC. - «Данные идут через MV и FDW, правка только в коде может не дать эффекта» — для C3 неверно. Второй `1.0` (`basis=city_fallback` в 99a) и district-фолбэк имеют `n_deals` 3–4, эстиматор требует `n_deals >= 10`. На проде до него доходят только строки `basis=quarter` (district 0 из 281, city_fallback 0 из 1). MV/FDW трогать не нужно. Проверка направления на сделках Росреестра (правка поднимает цену, поэтому проверялась отдельно): ошибка против реальных ДКП падает с −32…−40% до 0…+14% во всех шести городах. H1-H5 не трогал.
lekss361 added the
bug
priority/p1
scope/backend
scope/db
tradein
labels 2026-08-16 10:25:09 +00:00
Sign in to join this conversation.
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#2583
No description provided.