Merge pull request 'fix(tradein/sale-share): гард по этажности — отсечь near-neighbor mis-match' (#2084) from fix/tradein-floors-guard into main
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m26s
Deploy Trade-In / deploy (push) Successful in 51s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 53s
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m26s
Deploy Trade-In / deploy (push) Successful in 51s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 53s
This commit is contained in:
commit
a96af4c19c
3 changed files with 168 additions and 0 deletions
|
|
@ -142,6 +142,13 @@ def build_listings_query(house_id: int) -> tuple[str, dict[str, object]]:
|
||||||
самый дешёвый листинг; sources — все площадки, где квартира видна. Гео-фильтр ≤300м
|
самый дешёвый листинг; sources — все площадки, где квартира видна. Гео-фильтр ≤300м
|
||||||
(мигр.150) против чужих листингов. Листинги/дома без geom — КЕЕП.
|
(мигр.150) против чужих листингов. Листинги/дома без geom — КЕЕП.
|
||||||
|
|
||||||
|
Floors-guard (мигр.152, та же логика, что во view): отбрасываем листинги, чья
|
||||||
|
этажность (l.total_floors) отличается от этажности дома
|
||||||
|
(COALESCE(h.zhkh_floors, h.total_floors)) более чем на 3 — отсекает близких соседей
|
||||||
|
в радиусе 300м (5-этажка vs соседние 9/16/19-этажные башни). Листинги с неизвестной
|
||||||
|
этажностью листинга ИЛИ неизвестной этажностью дома — КЕЕП (режем только явный
|
||||||
|
mismatch >3).
|
||||||
|
|
||||||
DISTINCT ON-ключи (rooms, round(area_m2), floor) лидируют в ORDER BY, поэтому
|
DISTINCT ON-ключи (rooms, round(area_m2), floor) лидируют в ORDER BY, поэтому
|
||||||
представитель детерминирован (price_rub ASC NULLS LAST, id → берётся самый дешёвый).
|
представитель детерминирован (price_rub ASC NULLS LAST, id → берётся самый дешёвый).
|
||||||
round(area_m2), а не raw area_m2, используется и в DISTINCT ON, и в ORDER BY, и в
|
round(area_m2), а не raw area_m2, используется и в DISTINCT ON, и в ORDER BY, и в
|
||||||
|
|
@ -160,6 +167,9 @@ def build_listings_query(house_id: int) -> tuple[str, dict[str, object]]:
|
||||||
" AND l.is_active AND l.listing_segment = 'vtorichka' "
|
" AND l.is_active AND l.listing_segment = 'vtorichka' "
|
||||||
" AND (l.geom IS NULL OR h.geom IS NULL "
|
" AND (l.geom IS NULL OR h.geom IS NULL "
|
||||||
" OR ST_DistanceSphere(l.geom, h.geom) <= 300)"
|
" OR ST_DistanceSphere(l.geom, h.geom) <= 300)"
|
||||||
|
" AND (l.total_floors IS NULL "
|
||||||
|
" OR COALESCE(h.zhkh_floors, h.total_floors) IS NULL "
|
||||||
|
" OR abs(l.total_floors - COALESCE(h.zhkh_floors, h.total_floors)) <= 3)"
|
||||||
"), src AS ("
|
"), src AS ("
|
||||||
" SELECT rooms, round(area_m2) AS sig_area, floor, "
|
" SELECT rooms, round(area_m2) AS sig_area, floor, "
|
||||||
" array_agg(DISTINCT source ORDER BY source) AS sources "
|
" array_agg(DISTINCT source ORDER BY source) AS sources "
|
||||||
|
|
|
||||||
155
tradein-mvp/backend/data/sql/152_sale_share_floors_guard.sql
Normal file
155
tradein-mvp/backend/data/sql/152_sale_share_floors_guard.sql
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
-- 152_sale_share_floors_guard.sql
|
||||||
|
--
|
||||||
|
-- CONTEXT: гео-фильтр ≤300м (мигр. 150) слишком широк для плотной застройки. К
|
||||||
|
-- 5-этажному дому (house «Мраморская 34к4») примагничиваются чужие листинги
|
||||||
|
-- Благодатская 15 / 19-этажных башен на 229-282м: <300м → гео-предикат их НЕ
|
||||||
|
-- отсекает, числитель/медианы остаются загрязнены. Сигнал отсева — этажность:
|
||||||
|
-- дом 5 эт., а листинги заявляют total_floors 9/16/19 → это ДРУГОЕ здание.
|
||||||
|
-- Прод-замер: гард `abs(l.total_floors - building_floors) > 3` отсекает 2754
|
||||||
|
-- листинга (12%) — height-mismatched mis-bucketing, не отрезаемый одной геометрией.
|
||||||
|
--
|
||||||
|
-- WHAT:
|
||||||
|
-- 1) houses.zhkh_floors (smallint) — распарсенная этажность из ГИС ЖКХ-поля
|
||||||
|
-- zhkh_floors_raw (text вида "10-30" / "8,чердак"): max целого числа из строки.
|
||||||
|
-- ЖКХ-источник точнее total_floors, потому в гарде приоритетнее (COALESCE).
|
||||||
|
-- 2) v_building_sale_share — в CTE listing_agg к КАЖДОЙ из 5 FILTER-клауз
|
||||||
|
-- (active_secondary, listings_45d, median_price_rub, median_price_per_m2,
|
||||||
|
-- avg_days_on_market) ANDом добавлен floors-guard:
|
||||||
|
-- `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`
|
||||||
|
-- Кепим листинг, у которого этажность неизвестна, ИЛИ этажность дома неизвестна,
|
||||||
|
-- ИЛИ расходится с домом не более чем на ±3 этажа. Существующий гео-предикат
|
||||||
|
-- ≤300м (мигр. 150) СОХРАНЁН во всех 5 FILTER — guard'ы конъюнктивны.
|
||||||
|
-- Знаменатель (flat_count_effective, плаузибилити-гейты, COALESCE-приоритет ЖКХ),
|
||||||
|
-- весь top-level SELECT / WHERE / appended-колонки (zhkh_flat_count, flat_count_source)
|
||||||
|
-- — БАЙТ-В-БАЙТ как в мигр. 150, дельта только внутри CTE listing_agg + новый
|
||||||
|
-- ALTER/UPDATE houses.zhkh_floors выше view.
|
||||||
|
--
|
||||||
|
-- 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 (дедуп
|
||||||
|
-- кросс-площадочных дублей), 149 (ЖКХ-приоритет знаменателя + zhkh_flat_count/
|
||||||
|
-- flat_count_source колонки + houses.zhkh_floors_raw text), 150 (гео-фильтр числителя
|
||||||
|
-- ≤300м в CTE), 151 (clean bare-street aliases — view НЕ трогала). Базируется на
|
||||||
|
-- текущем (150) определении view — меняем ТОЛЬКО CTE listing_agg.
|
||||||
|
-- Использует listings.total_floors (int), houses.total_floors (int),
|
||||||
|
-- houses.zhkh_floors_raw (text, мигр. 149) → houses.zhkh_floors (smallint, ниже).
|
||||||
|
--
|
||||||
|
-- SAFETY / IDEMPOTENCY: ALTER TABLE ADD COLUMN IF NOT EXISTS (повторный прогон no-op),
|
||||||
|
-- UPDATE с `zhkh_floors IS DISTINCT FROM <parsed>` guard'ом (идемпотентен — на 2-м
|
||||||
|
-- прогоне 0 строк), CREATE OR REPLACE VIEW (структура колонок не меняется — те же
|
||||||
|
-- позиции/типы/имена, что в 150) + COMMENT. Деплой-раннер гонит файл через
|
||||||
|
-- psql -v ON_ERROR_STOP=on БЕЗ --single-transaction → транзакцию открывает САМ файл
|
||||||
|
-- (BEGIN/COMMIT ниже), как 146/148/149/150.
|
||||||
|
--
|
||||||
|
-- NB по нумерации: последний занятый = 151; следующий свободный sequential = 152
|
||||||
|
-- (проверено `ls tradein-mvp/backend/data/sql | grep '^15'` → 150, 151; коллизий нет).
|
||||||
|
--
|
||||||
|
-- Deploy order: после 151_clean_bare_street_aliases.sql.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Распарсенная этажность ЖКХ (из zhkh_floors_raw "10-30"/"8,чердак" → max int) для гарда.
|
||||||
|
ALTER TABLE houses ADD COLUMN IF NOT EXISTS zhkh_floors smallint;
|
||||||
|
UPDATE houses
|
||||||
|
SET zhkh_floors = (SELECT max((m[1])::int)
|
||||||
|
FROM regexp_matches(zhkh_floors_raw, '\d+', 'g') AS m)
|
||||||
|
WHERE zhkh_floors_raw IS NOT NULL
|
||||||
|
AND zhkh_floors IS DISTINCT FROM
|
||||||
|
(SELECT max((m[1])::int) FROM regexp_matches(zhkh_floors_raw, '\d+', 'g') AS m);
|
||||||
|
|
||||||
|
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) 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)) 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
|
||||||
|
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 — кепим. Знаменатель НЕ изменён. Поверх гео — floors-guard (мигр. 152): листинг входит в '
|
||||||
|
'числитель/медианы, только если его total_floors неизвестен, либо этажность дома '
|
||||||
|
'COALESCE(zhkh_floors, total_floors) неизвестна, либо |разница| <= 3 этажа — режет height-'
|
||||||
|
'mismatched mis-bucketing (5-эт. дом vs 19-эт. башни <300м), который гео-фильтр пропускает.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -130,6 +130,9 @@ def test_listings_query_shape():
|
||||||
assert "is_active" in sql
|
assert "is_active" in sql
|
||||||
assert "JOIN houses" in sql
|
assert "JOIN houses" in sql
|
||||||
assert "ST_DistanceSphere(l.geom, h.geom) <= 300" in sql
|
assert "ST_DistanceSphere(l.geom, h.geom) <= 300" in sql
|
||||||
|
# floors-consistency guard (мигр.152): отсекает соседей с расхождением этажности >3,
|
||||||
|
# NULL-этажность листинга/дома — КЕЕП.
|
||||||
|
assert "abs(l.total_floors - COALESCE(h.zhkh_floors, h.total_floors)) <= 3" in sql
|
||||||
assert "id AS listing_id" in sql
|
assert "id AS listing_id" in sql
|
||||||
assert args["house_id"] == 42
|
assert args["house_id"] == 42
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue