fix(tradein/sql): цена в подписи дедупликации «доли квартир дома в продаже» (#2539)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m53s
Deploy Trade-In / build-backend (push) Successful in 28s
Deploy Trade-In / deploy (push) Successful in 1m6s
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m53s
Deploy Trade-In / build-backend (push) Successful in 28s
Deploy Trade-In / deploy (push) Successful in 1m6s
This commit is contained in:
parent
76016fd469
commit
a450aed71b
1 changed files with 231 additions and 0 deletions
|
|
@ -0,0 +1,231 @@
|
||||||
|
-- 190_sale_share_price_bucket_signature.sql
|
||||||
|
--
|
||||||
|
-- CONTEXT: аудит МЕРЫ. Числитель v_building_sale_share (мигр. 148) дедупит
|
||||||
|
-- листинги по сигнатуре (rooms, round(area_m2), floor) — убирает кросс-
|
||||||
|
-- площадочные дубли одной физической квартиры (avito+cian+domclick). Но в
|
||||||
|
-- типовом секционном доме 4 РАЗНЫЕ квартиры на одном этаже в разных
|
||||||
|
-- подъездах имеют ТУ ЖЕ тройку признаков (подъезда в данных нет) → ложно
|
||||||
|
-- схлопываются в одну.
|
||||||
|
--
|
||||||
|
-- Прод-замер (снят вручную, до этой миграции; не переснят в рамках неё —
|
||||||
|
-- нет доступа к БД из этой сессии, см. ниже):
|
||||||
|
-- · без дедупа (активные вторичные, house_id_fk/rooms/area_m2/floor/
|
||||||
|
-- price_rub все NOT NULL): 15 424 записи;
|
||||||
|
-- · текущая сигнатура (rooms, round(area_m2), floor): 11 324 «квартиры»
|
||||||
|
-- (−4 100 против raw — почти весь эффект дедупа, но и false-merge тоже);
|
||||||
|
-- · та же сигнатура + price_bucket round(price_rub/100000): 12 497
|
||||||
|
-- (+1 173 против текущей, +10.4%) — возвращает часть false-merge'ов.
|
||||||
|
-- Внутри 3 220 групп, схлопнутых текущей сигнатурой:
|
||||||
|
-- · 1 252 группы (1 756 записей) имеют РАЗНЫЕ цены — почти наверняка
|
||||||
|
-- разные квартиры, не кросс-пост;
|
||||||
|
-- · 233 группы (249 записей) пришли с ОДНОЙ площадки — одна площадка
|
||||||
|
-- редко публикует одну и ту же квартиру дважды, тоже почти наверняка
|
||||||
|
-- разные квартиры (см. "residual risk" ниже — этот класс НЕ решается
|
||||||
|
-- одним лишь price_bucket, если у них к тому же совпала цена).
|
||||||
|
--
|
||||||
|
-- РЕШЕНИЕ ВЛАДЕЛЬЦА ПРОДУКТА: схлопывать записи, только если они совпадают
|
||||||
|
-- ЕЩЁ И по цене (round(price_rub/100000) — тот же бакет, что уже
|
||||||
|
-- используется в backend/app/services/estimator.py::_DEDUP_PRICE_BUCKET_RUB
|
||||||
|
-- для кросс-source физ-дедупа аналогов; ~±0.5% допуска при 21М, ~±2% при
|
||||||
|
-- 2.5М — см. app/core/config.py:265). Разные квартиры в одном доме
|
||||||
|
-- почти никогда не стоят ровно одинаково, кросс-пост одного лота — стоит.
|
||||||
|
--
|
||||||
|
-- ЧТО НЕ ВОШЛО (source-distinctness) и почему:
|
||||||
|
-- Продуктовое решение также просило требовать "с разных площадок". Честно
|
||||||
|
-- выразить это внутри count(DISTINCT ...) НЕЛЬЗЯ без перестройки CTE
|
||||||
|
-- listing_agg в двухуровневую агрегацию (сначала GROUP BY house_id +
|
||||||
|
-- расширенная сигнатура + count(DISTINCT source) per группа, потом per-house
|
||||||
|
-- SUM(CASE WHEN distinct_sources>=2 THEN 1 ELSE listing_count END)) — это
|
||||||
|
-- затронуло бы ВСЕ 6 агрегатов CTE (active_secondary, listings_45d,
|
||||||
|
-- median_price_rub, median_price_per_m2, avg_days_on_market,
|
||||||
|
-- listings_med_floors), которые сейчас делят один плоский FILTER-паттерн,
|
||||||
|
-- накопленный за 6 миграций (148-153). Риск регрессии от такой перестройки
|
||||||
|
-- в одной миграции выше, чем ценность второго guard'а поверх уже сильно
|
||||||
|
-- сузившего false-merge price_bucket. Берём только price-часть.
|
||||||
|
--
|
||||||
|
-- RESIDUAL RISK (направление ошибки после этой миграции):
|
||||||
|
-- 1) НЕ решено — 233 группы/249 записей с ОДНОЙ площадкой: если у них
|
||||||
|
-- внутри группы цена ТОЖЕ совпадает (не проверено, нет прод-доступа
|
||||||
|
-- в этой сессии), они останутся ложно схлопнуты (недосчёт числителя,
|
||||||
|
-- sale_share_pct ЗАНИЖЕН для этих домов) — тот же вид ошибки, что и
|
||||||
|
-- раньше, но у существенно меньшего подмножества.
|
||||||
|
-- 2) НОВЫЙ вид ошибки, которого не было: настоящий кросс-пост одного
|
||||||
|
-- физлота, где цена УСПЕЛА измениться между скрейпами разных площадок
|
||||||
|
-- (снизили цену на avito, domclick ещё не досканирован) — теперь НЕ
|
||||||
|
-- схлопнется (разные price_bucket) → числитель ЗАВЫШЕН для этих домов.
|
||||||
|
-- Раньше такая пара схлопывалась верно (без price в ключе). Прямого
|
||||||
|
-- прод-замера размера этого класса нет.
|
||||||
|
-- Итого: миграция МЕНЯЕТ баланс ошибки с «сильный недосчёт от false-merge
|
||||||
|
-- по этажу/подъезду» на «слабый недосчёт по одноплощадочным совпадениям +
|
||||||
|
-- небольшой new-пересчёт по кросс-постам с ценовым дрейфом» — чище, но не
|
||||||
|
-- идеально в обе стороны.
|
||||||
|
--
|
||||||
|
-- price_rub NULL/0 handling: listings.price_rub объявлена `bigint NOT NULL`
|
||||||
|
-- (002_core_tables.sql), но код уже трактует её defensively как потенциально
|
||||||
|
-- отсутствующую (146/148: `l.price_rub IS NOT NULL` в median FILTER) — то же
|
||||||
|
-- делаем здесь. price_bucket-компонент = NULL, когда price_rub IS NULL ИЛИ
|
||||||
|
-- <= 0 (0/отрицательное — sentinel нераспарсенной цены, не реальная цена).
|
||||||
|
-- Партиально-NULL кортеж (rooms/area/floor есть, price_bucket NULL)
|
||||||
|
-- count(DISTINCT ROW(...)) трактует как СВОЙ отдельный кортеж (см. NULL-
|
||||||
|
-- handling мигр. 148) — т.е. листинг без подтверждённой цены НЕ схлопывается
|
||||||
|
-- ни с чем, считается один. Консервативно (не создаёт ложных совпадений по
|
||||||
|
-- цене) и совпадает с философией estimator.py::_lot_dedup_components
|
||||||
|
-- (`if not price: composite = None` → лот не участвует в физ-дедупе).
|
||||||
|
--
|
||||||
|
-- WHAT: в CTE listing_agg расширяем сигнатуру DISTINCT В ОБОИХ числителях
|
||||||
|
-- (active_secondary, listings_45d) с (rooms, round(area_m2), floor) до
|
||||||
|
-- (rooms, round(area_m2), floor, price_bucket), где price_bucket = CASE
|
||||||
|
-- WHEN l.price_rub IS NULL OR l.price_rub <= 0 THEN NULL
|
||||||
|
-- ELSE round(l.price_rub / 100000.0) END.
|
||||||
|
-- Остальные 4 агрегата CTE (median_price_rub, median_price_per_m2,
|
||||||
|
-- avg_days_on_market, listings_med_floors) — НЕ дедуп-based (считают по
|
||||||
|
-- сырым листингам, прошедшим FILTER), не трогаем. Весь top-level SELECT /
|
||||||
|
-- WHERE / плаузибилити-гейт (мигр. 145/153) / appended-колонки
|
||||||
|
-- (zhkh_flat_count, flat_count_source) / гео(≤300м, мигр.150) / floors-guard
|
||||||
|
-- (±3, мигр.152) — БАЙТ-В-БАЙТ как в мигр. 153.
|
||||||
|
--
|
||||||
|
-- DEPENDENCIES: 143 (view + houses.gar_*), 144 (canon match → gar_flat_count),
|
||||||
|
-- 145 (плаузибилити-гейт знаменателя), 146 (listings_45d + sale_share_pct_45d
|
||||||
|
-- + zhkh в COALESCE), 147 (canon strip geo-prefixes), 148 (дедуп
|
||||||
|
-- кросс-площадочных дублей — база сигнатуры, которую здесь расширяем), 149
|
||||||
|
-- (ЖКХ-приоритет знаменателя), 150 (гео-фильтр ≤300м в CTE), 151 (bare-street
|
||||||
|
-- aliases — view не трогала), 152 (floors-guard ±3), 153 (плаузибилити по
|
||||||
|
-- листинговой медианной этажности). Базируется на текущем (153) определении
|
||||||
|
-- view — меняем ТОЛЬКО DISTINCT-выражение в active_secondary/listings_45d.
|
||||||
|
--
|
||||||
|
-- SAFETY / IDEMPOTENCY: CREATE OR REPLACE VIEW ONLY (структура top-level
|
||||||
|
-- колонок не меняется — те же позиции/типы/имена, что в 153) + COMMENT.
|
||||||
|
-- Никакого DDL над таблицами. Повторный прогон — no-op (REPLACE на
|
||||||
|
-- идентичное определение). Деплой-раннер гонит файл через
|
||||||
|
-- psql -v ON_ERROR_STOP=on БЕЗ --single-transaction → транзакцию открывает
|
||||||
|
-- САМ файл (BEGIN/COMMIT ниже), как 146/148/149/150/152/153.
|
||||||
|
--
|
||||||
|
-- CONSUMERS (грепнуто по backend+frontend, не тронуты этой миграцией):
|
||||||
|
-- backend/app/services/buildings_query.py — SELECT * колонок view (список,
|
||||||
|
-- summary, гистограмма) — тот же набор колонок, не ломается;
|
||||||
|
-- backend/app/schemas/buildings.py, backend/app/api/v1/buildings.py —
|
||||||
|
-- Pydantic-схема поверх тех же колонок, не ломается;
|
||||||
|
-- backend/tests/test_buildings_api.py — тестирует ТОЛЬКО текст SQL-билдеров
|
||||||
|
-- (строку "FROM v_building_sale_share" и т.п.), не внутренний DISTINCT view
|
||||||
|
-- → не ломается этой миграцией;
|
||||||
|
-- ⚠ backend/app/services/buildings_query.py::build_listings_query — ОТДЕЛЬНЫЙ
|
||||||
|
-- SQL (не читает view), реализует ТУ ЖЕ (rooms, round(area_m2), floor)
|
||||||
|
-- сигнатуру САМОСТОЯТЕЛЬНО (DISTINCT ON) для панели листингов одного дома.
|
||||||
|
-- После этой миграции сигнатуры /buildings/sale-share (список, через view,
|
||||||
|
-- теперь +price_bucket) и /buildings/{id}/listings (панель, старая 3-тройка)
|
||||||
|
-- РАСХОДЯТСЯ — на детальной панели дома возможен чуть меньший count уникальных
|
||||||
|
-- квартир, чем active_secondary в списке. НЕ трогаем buildings_query.py в
|
||||||
|
-- этой миграции (вне границ задачи) — фиксируем расхождение как known
|
||||||
|
-- follow-up для отдельной задачи.
|
||||||
|
--
|
||||||
|
-- NB по нумерации: последний занятый = 188 (187/188 заняты веб-чатом);
|
||||||
|
-- следующий свободный sequential = 189 (проверено `ls tradein-mvp/backend/
|
||||||
|
-- data/sql | grep '^18'` — 187, 188 заняты, 189 свободен; дубля basename нет).
|
||||||
|
--
|
||||||
|
-- Deploy order: после 188_tg_support_chat_id_scope.sql.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW v_building_sale_share AS
|
||||||
|
WITH listing_agg AS (
|
||||||
|
SELECT l.house_id_fk AS house_id,
|
||||||
|
count(DISTINCT (l.rooms, round(l.area_m2), l.floor,
|
||||||
|
CASE WHEN l.price_rub IS NULL OR l.price_rub <= 0 THEN NULL
|
||||||
|
ELSE round(l.price_rub / 100000.0) END))
|
||||||
|
FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS active_secondary,
|
||||||
|
count(DISTINCT (l.rooms, round(l.area_m2), l.floor,
|
||||||
|
CASE WHEN l.price_rub IS NULL OR l.price_rub <= 0 THEN NULL
|
||||||
|
ELSE round(l.price_rub / 100000.0) END)) FILTER (
|
||||||
|
WHERE l.listing_segment = 'vtorichka'::text
|
||||||
|
AND l.last_seen_at >= (now() - interval '45 days')
|
||||||
|
AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300)
|
||||||
|
AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)
|
||||||
|
) AS listings_45d,
|
||||||
|
percentile_cont(0.5::double precision) WITHIN GROUP (ORDER BY (l.price_rub::double precision))
|
||||||
|
FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.price_rub IS NOT NULL AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS median_price_rub,
|
||||||
|
percentile_cont(0.5::double precision) WITHIN GROUP (ORDER BY (l.price_per_m2::double precision))
|
||||||
|
FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.price_per_m2 IS NOT NULL AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS median_price_per_m2,
|
||||||
|
avg(l.days_on_market)
|
||||||
|
FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.days_on_market IS NOT NULL AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS avg_days_on_market,
|
||||||
|
percentile_cont(0.5) WITHIN GROUP (ORDER BY l.total_floors)
|
||||||
|
FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS listings_med_floors
|
||||||
|
FROM listings l
|
||||||
|
JOIN houses hg ON hg.id = l.house_id_fk
|
||||||
|
WHERE l.house_id_fk IS NOT NULL
|
||||||
|
GROUP BY l.house_id_fk
|
||||||
|
)
|
||||||
|
SELECT h.id AS house_id,
|
||||||
|
h.short_address,
|
||||||
|
h.full_address,
|
||||||
|
h.address,
|
||||||
|
h.lat,
|
||||||
|
h.lon,
|
||||||
|
h.year_built,
|
||||||
|
h.house_type,
|
||||||
|
h.total_floors,
|
||||||
|
h.series_name,
|
||||||
|
h.is_emergency,
|
||||||
|
COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) AS flat_count_effective,
|
||||||
|
h.gar_flat_count,
|
||||||
|
h.gar_match_method,
|
||||||
|
la.active_secondary,
|
||||||
|
la.median_price_rub,
|
||||||
|
la.median_price_per_m2,
|
||||||
|
la.avg_days_on_market,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))
|
||||||
|
>= GREATEST(COALESCE(h.total_floors, 0), COALESCE(la.listings_med_floors, 0)::int, 8)
|
||||||
|
AND la.active_secondary <= COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))
|
||||||
|
THEN round(100.0 * la.active_secondary::numeric
|
||||||
|
/ COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))::numeric, 1)
|
||||||
|
ELSE NULL::numeric
|
||||||
|
END AS sale_share_pct,
|
||||||
|
la.listings_45d,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))
|
||||||
|
>= GREATEST(COALESCE(h.total_floors, 0), COALESCE(la.listings_med_floors, 0)::int, 8)
|
||||||
|
AND la.listings_45d <= COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))
|
||||||
|
THEN round(100.0 * la.listings_45d::numeric
|
||||||
|
/ COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))::numeric, 1)
|
||||||
|
ELSE NULL::numeric
|
||||||
|
END AS sale_share_pct_45d,
|
||||||
|
h.zhkh_flat_count,
|
||||||
|
CASE
|
||||||
|
WHEN h.zhkh_flat_count IS NOT NULL THEN 'zhkh'
|
||||||
|
WHEN h.gar_flat_count IS NOT NULL THEN 'gar'
|
||||||
|
WHEN NULLIF(h.total_units, 0) IS NOT NULL THEN 'total_units'
|
||||||
|
WHEN NULLIF(h.flat_count, 0) IS NOT NULL THEN 'flat_count'
|
||||||
|
ELSE NULL::text
|
||||||
|
END AS flat_count_source
|
||||||
|
FROM houses h
|
||||||
|
JOIN listing_agg la ON la.house_id = h.id
|
||||||
|
WHERE h.geom IS NOT NULL AND (la.active_secondary > 0 OR la.listings_45d > 0);
|
||||||
|
|
||||||
|
COMMENT ON VIEW v_building_sale_share IS
|
||||||
|
'Per-building rollup вторички для «доли квартир дома в продаже» (мигр. 143; знаменатель — '
|
||||||
|
'ГАР canon-match мигр. 144; 2-й источник ЖКХ + окно 45д мигр. 146; дедуп кросс-площадочных '
|
||||||
|
'дублей мигр. 148 + price_bucket мигр. 189; ЖКХ-приоритет знаменателя мигр. 149; гео-фильтр '
|
||||||
|
'числителя ≤300м мигр. 150). flat_count_effective = '
|
||||||
|
'COALESCE(zhkh_flat_count, gar_flat_count, NULLIF(total_units,0), NULLIF(flat_count,0)) — '
|
||||||
|
'ЖКХ ПРИОРИТЕТ (ГИС ЖКХ точнее ГАР, который дико недосчитывает квартиры в МКД; мигр. 149). '
|
||||||
|
'Колонки zhkh_flat_count (сырой ЖКХ-счёт) + flat_count_source (zhkh|gar|total_units|flat_count|'
|
||||||
|
'NULL — какой источник реально дал знаменатель) добавлены для прозрачности. Оба числителя '
|
||||||
|
'считают УНИКАЛЬНЫЕ КВАРТИРЫ по сигнатуре count(DISTINCT (rooms, round(area_m2), floor, '
|
||||||
|
'price_bucket)), где price_bucket = round(price_rub/100000) ИЛИ NULL при price_rub NULL/<=0 '
|
||||||
|
'(мигр. 189: одна тройка rooms/area/floor не отличает соседние квартиры на одном этаже в разных '
|
||||||
|
'подъездах — совпадение ЕЩЁ И по цене резко снижает false-merge; NULL-цена не схлопывается ни с '
|
||||||
|
'чем, считается отдельно — та же партиально-NULL философия, что и в мигр. 148 для rooms/area/'
|
||||||
|
'floor, и что в estimator.py::_lot_dedup_components для физ-дедупа аналогов). Требование '
|
||||||
|
'«разных площадок» из продуктового решения НЕ выражено в SQL (потребовало бы двухуровневой '
|
||||||
|
'агрегации across всех 6 FILTER-агрегатов CTE) — residual risk: однисточниковые группы с '
|
||||||
|
'совпавшей ценой остаются ложно схлопнуты; кросс-посты с ценовым дрейфом между скрейпами '
|
||||||
|
'перестают схлопываться (см. комментарий мигр. 189 в файле). active_secondary = FILTER '
|
||||||
|
'(is_active AND vtorichka); listings_45d = FILTER (vtorichka AND last_seen_at>=now()-45d). '
|
||||||
|
'sale_share_pct = active_secondary/denom; sale_share_pct_45d = listings_45d/denom. Оба под '
|
||||||
|
'плаузибилити-гейтом (denom>=GREATEST(total_floors, листинговая-медианная-этажность, 8) AND '
|
||||||
|
'числитель<=denom; мигр. 145 + 153), иначе NULL. Фильтр: geom NOT NULL AND (active_secondary>0 '
|
||||||
|
'OR listings_45d>0) — churn-only дома тоже видны. active_secondary/listings_45d/медианы цены и '
|
||||||
|
'срока считают ТОЛЬКО листинги ≤300м от geom своего дома (мигр. 150) с floors-guard ±3 (мигр. '
|
||||||
|
'152). Листинги/дома без geom — кепим. Знаменатель НЕ изменён мигр. 189.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Loading…
Add table
Reference in a new issue