Витрина «сделки против объявлений» перестаёт пустовать: снят предикат по синтетической комнатности сделок (#3451)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Successful in 38s
Deploy Trade-In / build-frontend (push) Successful in 2m15s
Deploy Trade-In / test (push) Successful in 4m35s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 1m31s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s

This commit is contained in:
bot-backend 2026-09-12 09:03:05 +00:00
commit 5f2810b8c6
4 changed files with 374 additions and 6 deletions

View file

@ -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.

View file

@ -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.756.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;

View file

@ -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

View file

@ -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