Витрина «сделки против объявлений» перестаёт пустовать: снят предикат по синтетической комнатности сделок #3461
4 changed files with 374 additions and 6 deletions
|
|
@ -2488,8 +2488,12 @@ def get_sales_vs_listings(
|
|||
"""Pairs (ДКП-сделка, listing) для улицы целевого адреса (PR K / #564).
|
||||
|
||||
Для каждой ДКП-сделки Росреестра в окне `period_months` пытаемся найти
|
||||
matching listing на той же улице с такими же rooms / близкой area_m2 /
|
||||
listing_date в окне [deal_date - window_days, deal_date + 30d grace].
|
||||
matching listing на той же улице с близкой area_m2 / listing_date в окне
|
||||
[deal_date - window_days, deal_date + 30d grace]. Комнатность в ключе стоит
|
||||
ТОЛЬКО на стороне объявлений (`l.rooms = p_rooms`, комнатность клиента): у
|
||||
сделок Росреестра `rooms` — синтетика из площади, предикат по ней снят
|
||||
миграцией 300 (#3451/#3256). Поэтому `deal_rooms` в паре может не совпадать
|
||||
с запрошенным `rooms`.
|
||||
|
||||
Возвращаем LEFT JOIN: сделки без listing match сохраняются (listing_* = None),
|
||||
чтобы вычислить linkage_rate.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
-- 300_sales_vs_listings_deals_rooms_drop.sql
|
||||
-- Purpose: #3451 — TVF street_sales_vs_listings() фильтровала сделки по `d.rooms`,
|
||||
-- а `deals.rooms` у источника 'rosreestr' — НЕ комнатность, а бакет площади.
|
||||
-- Импортёр (tradein-mvp/deploy/import-rosreestr.sh) пишет туда
|
||||
-- `CASE WHEN area < 30 THEN 0 WHEN area < 44 THEN 1 WHEN area < 62 THEN 2
|
||||
-- WHEN area < 85 THEN 3 ELSE 4 END`
|
||||
-- — прод: 321 559 строк из 321 560 удовлетворяют `rooms == area_bucket(area_m2)`,
|
||||
-- max(rooms) = 4 (пятикомнатных в данных не бывает по построению). То есть предикат
|
||||
-- работал ВТОРЫМ фильтром по площади и спорил с полосой ±tolerance, которую функция
|
||||
-- считает сама: клиент 49 м² / 1к получал полосу 41.7–56.4 м², но `d.rooms = 1`
|
||||
-- оставлял из неё только < 44 м².
|
||||
--
|
||||
-- Ровно эта патология снята в #3256 (PR #3445) на четырёх сделочных площадках
|
||||
-- эстиматора; здесь — последний оставшийся потребитель.
|
||||
--
|
||||
-- КЛЮЧ АСИММЕТРИЧНЫЙ, копипастой из #3256 не чинится:
|
||||
-- - `d.rooms = p_rooms` в window_deals — СНЯТ (синтетика из площади);
|
||||
-- - `l.rooms = p_rooms` в window_listings — ОСТАЁТСЯ: у объявлений комнатность
|
||||
-- настоящая (приходит с карточки), и это единственный признак ассортимента
|
||||
-- на листинговой стороне.
|
||||
--
|
||||
-- Прод-замер (2026-09-12, БД tradein, 1160 реальных клиентских запросов из
|
||||
-- trade_in_estimates; улица извлеклась у 954, у 206 — известная H1 «адрес вне
|
||||
-- словаря», к этой правке отношения не имеет). Считалось тем же путём, что у
|
||||
-- продукта: street/city резолвятся extract_street_name()/_resolve_target_city():
|
||||
-- - непустой ответ /sales-vs-listings: 805 (84.4 %) → 899 (94.2 %), впервые
|
||||
-- непустых 94 клиента;
|
||||
-- - сделок в выборке суммарно: 68 147 → 88 816;
|
||||
-- - из них с подобранным объявлением (то, что реально показывается парами):
|
||||
-- 30 830 → 39 193;
|
||||
-- - выборка не сократилась НИ У КОГО (0 из 954) — предикат умел только резать.
|
||||
-- Прогноз из #3451 был «те же 180 клиентов»; измеренная величина — 94. Разница в
|
||||
-- том, что оценка 180 бралась по коридору эстиматора (другие period/tolerance и
|
||||
-- другой street-pattern), а не по этой витрине; в файл кладётся измеренное.
|
||||
--
|
||||
-- NULL здесь не появляется: ветка ELSE в CASE импортёра ловит и NULL-площадь
|
||||
-- (все WHEN дают NULL → ELSE 4), прод подтверждает 0 NULL в deals.rooms при
|
||||
-- source='rosreestr'. Поэтому `deal_rooms: int` в SalesListingPair остаётся
|
||||
-- обязательным полем — контракт API не меняется.
|
||||
--
|
||||
-- Что НЕ меняется и почему:
|
||||
-- - Сигнатура функции — те же 7 аргументов и те же типы, что после м.205/211.
|
||||
-- CREATE OR REPLACE с ИЗМЕНЁННЫМ списком типов создал бы ВТОРУЮ перегрузку
|
||||
-- вместо замены (грабли #2627) — здесь список побайтово тот же.
|
||||
-- - `is_active` по-прежнему НЕ фильтруется (снятые объявления и есть материал
|
||||
-- пейринга — объявление снимают ПОСЛЕ продажи), см. шапки 067/205/211.
|
||||
-- - Сегментный гард #2660/#1186 и city-предикаты #2583 H4 перенесены дословно.
|
||||
-- - Caller (app/api/v1/trade_in.py, /sales-vs-listings) не меняется.
|
||||
--
|
||||
-- ЗАВИСИМОСТИ: 211 (текущее тело + 7-арг сигнатура). Deploy order: после 299.
|
||||
-- Идемпотентность: CREATE OR REPLACE + COMMENT ON — re-run safe.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION street_sales_vs_listings(
|
||||
p_street_pattern text,
|
||||
p_area_m2 numeric,
|
||||
p_rooms integer,
|
||||
p_window_days integer DEFAULT 180,
|
||||
p_area_tolerance numeric DEFAULT 0.15,
|
||||
p_period_months integer DEFAULT 24,
|
||||
p_target_city text DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE (
|
||||
deal_id bigint,
|
||||
deal_date date,
|
||||
deal_price_rub bigint,
|
||||
deal_price_per_m2 integer,
|
||||
deal_area_m2 numeric,
|
||||
deal_rooms integer,
|
||||
deal_floor integer,
|
||||
deal_address text,
|
||||
listing_id bigint,
|
||||
listing_source text,
|
||||
listing_source_url text,
|
||||
listing_date date,
|
||||
listing_price_rub bigint,
|
||||
listing_price_per_m2 integer,
|
||||
listing_area_m2 numeric,
|
||||
days_listing_to_deal integer,
|
||||
discount_pct numeric
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
WITH window_deals AS (
|
||||
-- Сделки в улице + период. Фильтр по area + (#2583 H4) city.
|
||||
-- Предиката по d.rooms здесь НЕТ — #3256/#3451: deals.rooms у источника
|
||||
-- 'rosreestr' не комнатность, а бакет площади (см. шапку файла).
|
||||
SELECT
|
||||
d.id AS deal_id,
|
||||
d.deal_date AS deal_date,
|
||||
d.price_rub AS deal_price_rub,
|
||||
d.price_per_m2 AS deal_price_per_m2,
|
||||
d.area_m2 AS deal_area_m2,
|
||||
d.rooms AS deal_rooms,
|
||||
d.floor AS deal_floor,
|
||||
d.address AS deal_address
|
||||
FROM deals d
|
||||
WHERE d.source = 'rosreestr'
|
||||
AND d.address ILIKE p_street_pattern
|
||||
AND d.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance)
|
||||
AND p_area_m2 * (1.0 + p_area_tolerance)
|
||||
AND d.deal_date > NOW() - (p_period_months || ' months')::interval
|
||||
AND d.price_rub > 0
|
||||
-- #2583 H4: deals.city заполнена на 100% — строгое равенство.
|
||||
-- NULL p_target_city (город вне словаря) → фильтр не применяется.
|
||||
AND (p_target_city IS NULL OR LOWER(d.city) = LOWER(p_target_city))
|
||||
),
|
||||
window_listings AS (
|
||||
-- Кандидаты-listings на той же улице, rooms exact (у ОБЪЯВЛЕНИЙ комнатность
|
||||
-- настоящая — предикат законен и остаётся, #3451), area ±tolerance,
|
||||
-- (#2583 H4) тот же город что deals-сторона, (#2660) только вторичка.
|
||||
SELECT
|
||||
l.id AS listing_id,
|
||||
l.source AS listing_source,
|
||||
l.source_url AS listing_source_url,
|
||||
l.listing_date AS listing_date,
|
||||
l.price_rub AS listing_price_rub,
|
||||
l.price_per_m2 AS listing_price_per_m2,
|
||||
l.area_m2 AS listing_area_m2,
|
||||
l.rooms AS listing_rooms,
|
||||
COALESCE(l.listing_date, l.scraped_at::date) AS listing_event_date
|
||||
FROM listings l
|
||||
WHERE l.address ILIKE p_street_pattern
|
||||
AND l.rooms = p_rooms
|
||||
AND l.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance)
|
||||
AND p_area_m2 * (1.0 + p_area_tolerance)
|
||||
AND l.price_rub > 0
|
||||
AND COALESCE(l.listing_date, l.scraped_at::date)
|
||||
> NOW() - ((p_period_months + 6) || ' months')::interval
|
||||
-- #2583 H4: listings.city заполнена ЧАСТИЧНО (прод: avito 63%,
|
||||
-- yandex 19%, cian 4.6%, domklik 0.6%, n1 0%) — NULL считается "своим"
|
||||
-- (симметрично asking_to_sold_ratio.py #2583 H2), иначе строгий
|
||||
-- фильтр выбросил бы почти все listings кроме avito.
|
||||
AND (p_target_city IS NULL OR l.city IS NULL OR LOWER(l.city) = LOWER(p_target_city))
|
||||
-- #2660 novostroyki guard (#1186): к ДКП-сделке вторички нельзя
|
||||
-- подставлять лот застройщика — девелоперский прайс не торгуется и
|
||||
-- уводит показываемый «медианный торг». Прод: 27.3% кандидатов —
|
||||
-- первичка. NULL = legacy вторичка до м.011, оставляем.
|
||||
AND (l.listing_segment IS NULL OR l.listing_segment = 'vtorichka')
|
||||
),
|
||||
paired AS (
|
||||
-- LEFT JOIN: сохраняем все сделки даже если нет listing match.
|
||||
-- Для каждой сделки выбираем listing с listing_date ближайший
|
||||
-- к deal_date (предпочтительно перед сделкой).
|
||||
SELECT DISTINCT ON (wd.deal_id)
|
||||
wd.deal_id,
|
||||
wd.deal_date,
|
||||
wd.deal_price_rub,
|
||||
wd.deal_price_per_m2,
|
||||
wd.deal_area_m2,
|
||||
wd.deal_rooms,
|
||||
wd.deal_floor,
|
||||
wd.deal_address,
|
||||
wl.listing_id,
|
||||
wl.listing_source,
|
||||
wl.listing_source_url,
|
||||
wl.listing_date,
|
||||
wl.listing_price_rub,
|
||||
wl.listing_price_per_m2,
|
||||
wl.listing_area_m2,
|
||||
(wd.deal_date - wl.listing_event_date)::integer AS days_listing_to_deal,
|
||||
CASE
|
||||
WHEN wl.listing_price_rub IS NOT NULL AND wl.listing_price_rub > 0
|
||||
THEN ROUND(
|
||||
(wd.deal_price_rub - wl.listing_price_rub)::numeric
|
||||
/ wl.listing_price_rub * 100,
|
||||
2
|
||||
)
|
||||
ELSE NULL
|
||||
END AS discount_pct
|
||||
FROM window_deals wd
|
||||
LEFT JOIN window_listings wl
|
||||
ON wl.listing_event_date
|
||||
BETWEEN (wd.deal_date - (p_window_days || ' days')::interval)::date
|
||||
AND (wd.deal_date + interval '30 days')::date
|
||||
ORDER BY
|
||||
wd.deal_id,
|
||||
-- prefer listing event дата перед сделкой и ближе к ней
|
||||
CASE WHEN wl.listing_event_date IS NULL THEN 1 ELSE 0 END,
|
||||
CASE WHEN wl.listing_event_date <= wd.deal_date THEN 0 ELSE 1 END,
|
||||
ABS((wd.deal_date - wl.listing_event_date))
|
||||
)
|
||||
SELECT
|
||||
deal_id,
|
||||
deal_date,
|
||||
deal_price_rub,
|
||||
deal_price_per_m2,
|
||||
deal_area_m2,
|
||||
deal_rooms,
|
||||
deal_floor,
|
||||
deal_address,
|
||||
listing_id,
|
||||
listing_source,
|
||||
listing_source_url,
|
||||
listing_date,
|
||||
listing_price_rub,
|
||||
listing_price_per_m2,
|
||||
listing_area_m2,
|
||||
days_listing_to_deal,
|
||||
discount_pct
|
||||
FROM paired
|
||||
ORDER BY deal_date DESC;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION street_sales_vs_listings(text, numeric, integer, integer, numeric, integer, text) IS
|
||||
'Pairs (ДКП-сделка, listing) для улицы. PR K / issue #564 Foundation Phase 1, '
|
||||
'city-filter #2583 H4 (миграция 205), segment-guard #2660/#1186 (миграция 211), '
|
||||
'снятие предиката по d.rooms #3451/#3256 (миграция 300). '
|
||||
'Per-street matching: address ILIKE, area ±tolerance, rooms exact ТОЛЬКО на '
|
||||
'listings-стороне (deals.rooms у rosreestr — синтетика из площади), window_days '
|
||||
'до даты сделки (+30д grace), city-scope (p_target_city, deals строго / listings '
|
||||
'терпимо к NULL), listings — только вторичка (listing_segment IS NULL или '
|
||||
'vtorichka). Возвращает LEFT JOIN — сделки без listing match имеют '
|
||||
'listing_* = NULL. discount_pct = (deal - listing) / listing * 100. '
|
||||
'is_active намеренно НЕ фильтруется: снятые объявления и есть материал пейринга.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
"""Static guards для миграции 300 (#3451: снят предикат `d.rooms` в street_sales_vs_listings()).
|
||||
|
||||
Прод применяет data/sql построчно строго (ON_ERROR_STOP); полный DB-прогон требует живой
|
||||
БД — здесь фиксируем структурные инварианты, которые ломаются копипастой:
|
||||
|
||||
- предиката по `d.rooms` в теле НЕТ: `deals.rooms` у источника 'rosreestr' — бакет
|
||||
площади (импортёр, `deploy/import-rosreestr.sh`), а не комнатность, и предикат
|
||||
работал вторым фильтром по площади поверх полосы ±tolerance (#3256);
|
||||
- предикат по `l.rooms` ОСТАЛСЯ: ключ асимметричный, у объявлений комнатность
|
||||
настоящая. «Починить симметрично» — значит потерять единственный признак
|
||||
ассортимента на листинговой стороне;
|
||||
- сигнатура и RETURNS TABLE не изменились относительно 211 — иначе CREATE OR REPLACE
|
||||
создаст ВТОРУЮ перегрузку вместо замены (грабли #2627), и caller получит старое тело;
|
||||
- гард сегмента #2660/#1186 и city-предикаты #2583 H4 перенесены дословно, не потеряны;
|
||||
- транзакционность + идемпотентность (CREATE OR REPLACE, без DROP/TRUNCATE).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
||||
_MIGRATION_211 = _SQL_DIR / "211_sales_vs_listings_segment_guard.sql"
|
||||
_MIGRATION_300 = _SQL_DIR / "300_sales_vs_listings_deals_rooms_drop.sql"
|
||||
|
||||
_SIGNATURE_TYPES = ("text", "numeric", "integer", "integer", "numeric", "integer", "text")
|
||||
_SEGMENT_GUARD = "(l.listing_segment is null or l.listing_segment = 'vtorichka')"
|
||||
|
||||
|
||||
def _sql(path: Path = _MIGRATION_300) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _flat(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", text).strip().lower()
|
||||
|
||||
|
||||
def _function_body(path: Path = _MIGRATION_300) -> str:
|
||||
"""Тело функции между `AS $$` и `$$;`, без `--`-комментариев, нормализованное.
|
||||
|
||||
COMMENT ON FUNCTION — тоже исполняемый SQL, и его текст описывает фильтры словами
|
||||
(в т.ч. «rooms exact ТОЛЬКО на listings-стороне»). Проверки «такого предиката в
|
||||
запросе нет» обязаны смотреть только на тело, иначе краснеют от собственной цитаты.
|
||||
"""
|
||||
sql = _sql(path)
|
||||
start = sql.index("AS $$")
|
||||
end = sql.index("$$;", start)
|
||||
body = "\n".join(
|
||||
line.split("--", 1)[0] for line in sql[start:end].splitlines() if line.split("--", 1)[0]
|
||||
)
|
||||
return _flat(body)
|
||||
|
||||
|
||||
def _code_only(path: Path) -> str:
|
||||
"""SQL без `--`-комментариев.
|
||||
|
||||
Шапки 211 и 300 разные и сами упоминают «RETURNS TABLE» — искать границы блоков
|
||||
по сырому тексту значит сравнивать комментарий с кодом (ровно так этот тест и
|
||||
покраснел в первый раз).
|
||||
"""
|
||||
return "\n".join(
|
||||
line.split("--", 1)[0] for line in _sql(path).splitlines() if line.split("--", 1)[0]
|
||||
)
|
||||
|
||||
|
||||
def _function_signature(path: Path) -> str:
|
||||
sql = _code_only(path)
|
||||
start = sql.index("CREATE OR REPLACE FUNCTION street_sales_vs_listings(")
|
||||
end = sql.index("RETURNS TABLE", start)
|
||||
return _flat(sql[start:end])
|
||||
|
||||
|
||||
def _returns_table(path: Path) -> str:
|
||||
sql = _code_only(path)
|
||||
start = sql.index("RETURNS TABLE")
|
||||
end = sql.index("LANGUAGE sql", start)
|
||||
return _flat(sql[start:end])
|
||||
|
||||
|
||||
def test_migration_300_exists() -> None:
|
||||
assert _MIGRATION_300.exists(), f"missing migration: {_MIGRATION_300}"
|
||||
|
||||
|
||||
def test_migration_300_is_transactional_and_idempotent() -> None:
|
||||
sql = _sql()
|
||||
assert "BEGIN;" in sql
|
||||
assert "COMMIT;" in sql
|
||||
assert "CREATE OR REPLACE FUNCTION street_sales_vs_listings(" in sql
|
||||
flat = _flat(sql)
|
||||
assert "drop function" not in flat
|
||||
assert "truncate" not in flat
|
||||
# ровно одна замена — иначе вторая перезапишет первую молча
|
||||
assert flat.count("create or replace function street_sales_vs_listings") == 1
|
||||
|
||||
|
||||
def test_deals_side_has_no_rooms_predicate() -> None:
|
||||
"""Суть #3451: сделочная сторона больше не ключуется синтетическим rooms."""
|
||||
body = _function_body()
|
||||
assert "d.rooms = p_rooms" not in body
|
||||
assert "d.rooms=p_rooms" not in body
|
||||
|
||||
|
||||
def test_listings_side_keeps_rooms_predicate() -> None:
|
||||
"""Асимметрия ключа: у объявлений комнатность настоящая, предикат законен."""
|
||||
body = _function_body()
|
||||
assert "l.rooms = p_rooms" in body
|
||||
|
||||
|
||||
def test_deal_rooms_still_returned() -> None:
|
||||
"""Колонку не убирали: endpoint читает `deal_rooms` по имени, контракт API тот же."""
|
||||
assert "d.rooms as deal_rooms" in _function_body()
|
||||
assert "deal_rooms" in _returns_table(_MIGRATION_300)
|
||||
|
||||
|
||||
def test_signature_identical_to_211() -> None:
|
||||
"""Иначе CREATE OR REPLACE создаст перегрузку, а caller продолжит звать старое тело."""
|
||||
assert _function_signature(_MIGRATION_300) == _function_signature(_MIGRATION_211)
|
||||
sig = _function_signature(_MIGRATION_300)
|
||||
for t in _SIGNATURE_TYPES:
|
||||
assert t in sig
|
||||
|
||||
|
||||
def test_returns_table_identical_to_211() -> None:
|
||||
assert _returns_table(_MIGRATION_300) == _returns_table(_MIGRATION_211)
|
||||
|
||||
|
||||
def test_segment_and_city_guards_survived() -> None:
|
||||
"""Перенос тела не должен ронять чужие правки (#2660/#1186 и #2583 H4)."""
|
||||
body = _function_body()
|
||||
assert _SEGMENT_GUARD in body
|
||||
assert "lower(d.city) = lower(p_target_city)" in body
|
||||
assert "lower(l.city) = lower(p_target_city)" in body
|
||||
|
||||
|
||||
def test_only_the_rooms_predicate_differs_from_211() -> None:
|
||||
"""Тело 300 = тело 211 минус ровно один предикат — ничего лишнего не уехало."""
|
||||
body_211 = _function_body(_MIGRATION_211)
|
||||
body_300 = _function_body(_MIGRATION_300)
|
||||
assert body_211.replace("and d.rooms = p_rooms ", "") == body_300
|
||||
|
|
@ -76,10 +76,15 @@ docker exec "$SRC_PG" psql -U "$SRC_USER" -d "$SRC_DB" -v ON_ERROR_STOP=on -c "
|
|||
-- НЕ фильтруют по deals.rooms ИМЕННО потому, что здесь синтетика; с
|
||||
-- настоящей комнатностью предикат нужно вернуть, иначе все сайты молча
|
||||
-- продолжат ключеваться площадью.
|
||||
-- ОСТАВШИЙСЯ потребитель, который ВСЁ ЕЩЁ фильтрует по d.rooms:
|
||||
-- TVF street_sales_vs_listings (data/sql/211_sales_vs_listings_segment_guard.sql:89),
|
||||
-- её зовёт API /sales-vs-listings (trade_in.py). Там ключ асимметричный —
|
||||
-- d.rooms синтетика, l.rooms настоящая, — копипастой из #3256 не чинится.
|
||||
-- Потребителей, фильтрующих РАВЕНСТВОМ по d.rooms, больше нет: последний —
|
||||
-- TVF street_sales_vs_listings — расчищен миграцией 300 (#3451). Ключ там
|
||||
-- асимметричный: d.rooms снят, l.rooms ОСТАВЛЕН (у объявлений комнатность
|
||||
-- настоящая). Появится новый потребитель — сверься с этим якорем.
|
||||
-- НО: app/tasks/asking_to_sold_ratio.py:148,152 по-прежнему КЛЮЧУЕТСЯ этим
|
||||
-- бакетом (GROUP BY LEAST(GREATEST(rooms,0),4)) и намеренно зеркалит ту же
|
||||
-- синтетику на листинговой стороне (#2620). Поменяешь CASE на реальную
|
||||
-- комнатность — вернётся именно #2620 (миграция 23-55% объявлений между
|
||||
-- бакетами, ratio>1 в «4+»), а не только вопрос предикатов.
|
||||
CASE
|
||||
WHEN area < 30 THEN 0 WHEN area < 44 THEN 1
|
||||
WHEN area < 62 THEN 2 WHEN area < 85 THEN 3
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue