Merge pull request 'fix(tradein/sale-share): гео-фильтр числителя ≤300м — отсечь mis-bucketed листинги' (#2080) from feat/tradein-sale-share-geofilter into main
Some checks failed
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 55s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Failing after 10m2s
Some checks failed
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 55s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Failing after 10m2s
This commit is contained in:
commit
bd3562b6bd
3 changed files with 152 additions and 9 deletions
|
|
@ -136,15 +136,20 @@ def build_sale_share_query(
|
|||
|
||||
|
||||
def build_listings_query(house_id: int) -> tuple[str, dict[str, object]]:
|
||||
"""Активные вторичные объявления одного дома (ORDER BY price_rub)."""
|
||||
"""Активные вторичные объявления одного дома (ORDER BY price_rub).
|
||||
|
||||
Гео-фильтр (≤300м от geom дома) против mis-bucketing листингов в чужой дом —
|
||||
тот же порог, что в v_building_sale_share (мигр.150). Листинги/дома без geom — КЕЕП.
|
||||
"""
|
||||
sql = (
|
||||
"SELECT id AS listing_id, source, source_url, price_rub, price_per_m2, "
|
||||
"rooms, CAST(area_m2 AS double precision) AS area_m2, floor, total_floors, "
|
||||
"days_on_market, listing_date, address "
|
||||
"FROM listings "
|
||||
"WHERE house_id_fk = CAST(:house_id AS bigint) "
|
||||
"AND is_active AND listing_segment = 'vtorichka' "
|
||||
"ORDER BY price_rub ASC NULLS LAST, id"
|
||||
"SELECT l.id AS listing_id, l.source, l.source_url, l.price_rub, l.price_per_m2, "
|
||||
"l.rooms, CAST(l.area_m2 AS double precision) AS area_m2, l.floor, l.total_floors, "
|
||||
"l.days_on_market, l.listing_date, l.address "
|
||||
"FROM listings l JOIN houses h ON h.id = l.house_id_fk "
|
||||
"WHERE l.house_id_fk = CAST(:house_id AS bigint) "
|
||||
"AND l.is_active AND l.listing_segment = 'vtorichka' "
|
||||
"AND (l.geom IS NULL OR h.geom IS NULL OR ST_DistanceSphere(l.geom, h.geom) <= 300) "
|
||||
"ORDER BY l.price_rub ASC NULLS LAST, l.id"
|
||||
)
|
||||
return sql, {"house_id": house_id}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
-- 150_sale_share_listing_geo_filter.sql
|
||||
--
|
||||
-- CONTEXT: страница «доля квартир дома в продаже» (/trade-in/sale-share). Числитель
|
||||
-- view v_building_sale_share (active_secondary / listings_45d + медианы цены/срока)
|
||||
-- ЗАГРЯЗНЁН mis-bucketing'ом: листинги примагничиваются к НЕ ТОМУ дому (баг
|
||||
-- house_id_fk). Прод-замер: 373 дома (5.5%) / 1685 листингов (7.2%) дальше 300м от
|
||||
-- geom своего дома. Пример — house_id 131237 «Мамина-Сибиряка 126» (8-эт., ЖКХ=51 кв,
|
||||
-- ЗНАМЕНАТЕЛЬ ВЕРНЫЙ): из 20 активных листингов реально его лишь ~2, остальные —
|
||||
-- Свердлова 32/32Б (29-32 эт. башни), Азина 25/31, разброс до 2285м. Раздутый
|
||||
-- числитель → ложные высокие доли в ТОПе.
|
||||
--
|
||||
-- WHAT:
|
||||
-- v_building_sale_share — в CTE listing_agg числители/медианы считают ТОЛЬКО листинги
|
||||
-- в пределах 300м от geom своего дома (JOIN houses hg ON hg.id = l.house_id_fk +
|
||||
-- предикат `l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom)
|
||||
-- <= 300` во ВСЕХ 5 FILTER-клаузах: active_secondary, listings_45d, median_price_rub,
|
||||
-- median_price_per_m2, avg_days_on_market). 300м выбран по распределению расстояний:
|
||||
-- p90=97м, 91% листингов ≤150м — гео-шум привязки сохраняем, мусор-хвост режем.
|
||||
-- Листинги/дома БЕЗ geom — КЕПИМ (предикат пропускает NULL-geom), отбрасываем лишь
|
||||
-- тех, у кого geom есть и он >300м. ST_DistanceSphere возвращает МЕТРЫ (оба geom 4326).
|
||||
-- ЗНАМЕНАТЕЛЬ (flat_count_effective, плаузибилити-гейты, COALESCE-приоритет ЖКХ) и
|
||||
-- весь top-level SELECT / WHERE / appended-колонки — БАЙТ-В-БАЙТ как в мигр. 149, дельта
|
||||
-- только внутри CTE listing_agg.
|
||||
--
|
||||
-- DEPENDENCIES: 143 (view + houses.gar_*), 144 (canon match → gar_flat_count),
|
||||
-- 145 (плаузибилити-гейт знаменателя), 146 (listings_45d + sale_share_pct_45d + zhkh
|
||||
-- в COALESCE + houses.zhkh_flat_count), 147 (canon strip geo-prefixes), 148 (дедуп
|
||||
-- кросс-площадочных дублей — count(DISTINCT (rooms,round(area_m2),floor))),
|
||||
-- 149 (ЖКХ-приоритет знаменателя + zhkh_flat_count/flat_count_source колонки).
|
||||
-- Базируется на текущем (149) определении view — меняем ТОЛЬКО CTE listing_agg.
|
||||
-- Использует listings.geom + houses.geom (Point 4326, set listings_set_geom trigger,
|
||||
-- GIST-индексы listings_geom_idx / houses).
|
||||
--
|
||||
-- SAFETY / IDEMPOTENCY: только CREATE OR REPLACE VIEW (структура колонок не меняется —
|
||||
-- те же позиции/типы/имена, что в 149) + COMMENT. Повторный прогон — no-op. Деплой-
|
||||
-- раннер гонит файл через psql -v ON_ERROR_STOP=on БЕЗ --single-transaction →
|
||||
-- транзакцию открывает САМ файл (BEGIN/COMMIT ниже), как 146/148/149.
|
||||
--
|
||||
-- NB по нумерации: последний занятый = 149; следующий свободный sequential = 150
|
||||
-- (проверено `ls tradein-mvp/backend/data/sql | grep '^1[45]'` + открытые PR на дубль
|
||||
-- basename — коллизий нет).
|
||||
--
|
||||
-- Deploy order: после 149_zhkh_priority_denominator.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)) 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)) AS active_secondary,
|
||||
count(DISTINCT (l.rooms, round(l.area_m2), l.floor)) 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)
|
||||
) 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)) 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)) 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)) AS avg_days_on_market
|
||||
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), 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), 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; ЖКХ-приоритет знаменателя мигр. 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)), а не '
|
||||
'объявления: одна квартира на avito+cian+domclick = разные dedup_hash, но одна сигнатура. '
|
||||
'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), иначе NULL. Частично-NULL сигнатуры считаются раздельно '
|
||||
'(count(DISTINCT ROW) игнорит лишь полностью-NULL строку). Фильтр: geom NOT NULL AND '
|
||||
'(active_secondary>0 OR listings_45d>0) — churn-only дома (0 активных, но есть листинги за '
|
||||
'45д) тоже видны. active_secondary/listings_45d/медианы цены и срока теперь считают ТОЛЬКО '
|
||||
'листинги ≤300м от geom своего дома (JOIN houses hg + ST_DistanceSphere<=300 в каждой FILTER; '
|
||||
'анти-mis-bucketing раздутого числителя из-за бага house_id_fk; мигр. 150). Листинги/дома без '
|
||||
'geom — кепим. Знаменатель НЕ изменён.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -128,7 +128,9 @@ def test_listings_query_shape():
|
|||
assert "house_id_fk = CAST(:house_id AS bigint)" in sql
|
||||
assert "listing_segment = 'vtorichka'" in sql
|
||||
assert "is_active" in sql
|
||||
assert "ORDER BY price_rub" in sql
|
||||
assert "ORDER BY l.price_rub" in sql
|
||||
assert "JOIN houses" in sql
|
||||
assert "ST_DistanceSphere(l.geom, h.geom) <= 300" in sql
|
||||
assert "id AS listing_id" in sql
|
||||
assert args["house_id"] == 42
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue