From 1c714aeb88cb5fbb55678300a1e3f6570d343b4b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Tue, 15 Sep 2026 19:41:22 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20=D1=81=D1=87=D0=B8=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20asking=E2=86=92sold=20=D0=BA=D0=BE=D1=8D=D1=84?= =?UTF-8?q?=D1=84=D0=B8=D1=86=D0=B8=D0=B5=D0=BD=D1=82=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B3=D0=BB=D0=B0=D1=81=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D0=B3=D0=B5=D0=BE=D0=B3=D1=80=D0=B0=D1=84?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B2=D0=BD=D0=B5=20=D0=95=D0=9A=D0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Регионы кроме 66 фильтровали обе стороны ТОЛЬКО по region_code и соединяли их ТОЛЬКО по бакету комнат — sold-медиана и ask-медиана считались по разным географическим популяциям одного региона. Объявления смещены к дальней дешёвой периферии сильнее, чем сделки, поэтому область 50 давала 0.891 при 0.73 у Москвы и 0.73 у ЕКБ; разложение по кольцам 10 км показывает 0.808/0.847/0.951/0.688 ВНУТРИ колец и 0.81-0.85 в ближних кольцах, где лежит 76% сделок. Обе стороны теперь раскладываются по одной регулярной сетке 0.1°x0.2° (~11x12-16 км на широтах 45-60°N — масштаб, на котором замер показал устойчивость отношения), медианы берутся внутри ячейки, в итог идут только ячейки с обеими сторонами, и агрегация взвешена числом СДЕЛОК: ratio = Σ(w·sold)/Σ(w·ask). Сетка, а не кольца, потому что у региона 50 нет своего города-центра; совмещение по названию муниципалитета невозможно — listings.city там пуста (3 строки из 70 996). Путь ЕКБ (_REDERIVE_SQL) не тронут — там своя историческая калибровка городской квотой. Гарды и наблюдаемость: регион не получает НИ ОДНОЙ строки (старые всё равно удаляются), если сторон без geom больше половины, ячеек с обеими сторонами <3 или в пересечение попало <50% геокодированных сделок — оценка честно остаётся без коэффициента вместо неверного. Порегионные счётчики: ячеек на сторону, ячеек отброшено по порогу, доля сделок/объявлений вне пересечения и доля строк без geom (>25% — WARNING, чтобы негеокодированные строки не выпадали молча). district остаётся пустым: ячейка — промежуточная единица расчёта, а не публикации, а потребитель читает строго district = '' (гео-разрез — отдельная задача #647). --- .../backend/app/tasks/asking_to_sold_ratio.py | 474 ++++++++++++++---- .../tests/test_asking_to_sold_ratio.py | 46 +- .../tests/test_ratio_geography_match_3529.py | 249 +++++++++ 3 files changed, 665 insertions(+), 104 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_ratio_geography_match_3529.py diff --git a/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py b/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py index 39278451..d04413b5 100644 --- a/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py +++ b/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py @@ -305,105 +305,260 @@ _REDERIVE_SQL = text( """ ) -# ── Generic per-region derivation (#3512) ───────────────────────────────────── -# Байт-в-байт та же деривация, что _REDERIVE_SQL выше (тот же 12-мес трейлинг, -# ppm²-полоса, novostroyki/freshness/area_m2-гарды, area-бакет ask-стороны, порог -# 30/30, global -1 fallback) — ЕДИНСТВЕННОЕ отличие: городская квота ЕКБ (city ILIKE / -# city IS NULL OR city ILIKE) заменена на симметричный `region_code = :region_code` -# на ОБЕИХ сторонах. Городской квоты здесь не было и не нужно: она была костылём -# конкретно исторического asking-покрытия ЕКБ (#C2), а не общим правилом. +# ── Geography-matched per-region derivation (#3529) ─────────────────────────── +# ПРОБЛЕМА (#3512-путь, прод-замер 2026-09 по региону 50): обе стороны фильтровались +# ТОЛЬКО по region_code и соединялись ТОЛЬКО по бакету комнат — т.е. sold-медиана и +# ask-медиана считались по РАЗНЫМ географическим популяциям одного региона. +# Разложение обл.50 по кольцам 10 км от центра Москвы (сделки 12 мес vs активные объявления): +# 20-30 км: 12 189 сделок / 23 292 объявления → 0.808 +# 30-40 км: 4 132 / 10 206 → 0.847 +# 50-60 км: 1 432 / 5 243 → 0.951 +# 70-80 км: 150 / 2 294 → 0.688 +# ВНУТРИ колец отношение 0.69-0.95, ближние кольца (76% сделок) — 0.81-0.85, а общий пул +# давал 0.891: объявления смещены к дальней дешёвой периферии СИЛЬНЕЕ, чем сделки. Это +# перекос СОСТАВА выборки, а не свойство рынка: выкупная цена по области системно завышена. +# +# РЕШЕНИЕ: считать коэффициент на СОГЛАСОВАННОЙ географии — обе стороны раскладываются +# по одним и тем же пространственным ячейкам, медианы берутся ВНУТРИ ячейки, и в итог +# идут только ячейки, где есть ОБЕ стороны, с весами по числу СДЕЛОК. Т.е. ask-сторона +# перевзвешивается на географию сделок (индекс Ласпейреса): ratio = Σ(w·sold) / Σ(w·ask), +# w = n_deals ячейки. Строка остаётся внутренне согласованной: ratio == sold_median/ask_median, +# где оба медианных столбца — взвешенные средние ячеечных медиан с ОДНИМИ весами. +# +# ПУТЬ ЕКБ (66) НЕ ТРОГАЕМ — там своя историческая калибровка городской квотой (#C2/#2583), +# числа региона 66 обязаны остаться byte-for-byte прежними (_REDERIVE_SQL выше). + +# РАЗМЕР ЯЧЕЙКИ — регулярная сетка 0.1° широты × 0.2° долготы ≈ 11 км × 12-16 км на +# широтах 45-60°N (0.2° долготы × cos(lat): 15.7 км на 45°, 12.5 км на 55.7°, 11.1 км на 60°). +# Почему именно так: +# • Масштаб взят от замера выше: именно на ~10-км разрешении отношение перестаёт +# гулять от состава (внутри кольца 0.69-0.95 вместо 0.891 по пулу), при этом ячейка +# ещё достаточно крупная, чтобы набрать десятки сделок и объявлений. +# • Сетка, а НЕ кольца от центра: кольцам нужен центр, а у региона 50 своего +# города-центра нет (его фактический центр — Москва, т.е. ДРУГОЙ регион), и каждый +# следующий регион реестра потребовал бы своего анкора и своего шага. Сетке анкор не нужен. +# • Совмещение по НАЗВАНИЮ муниципалитета НЕВОЗМОЖНО: listings.city у региона 50 +# пуста (3 строки из 70 996). geom есть с обеих сторон (объявления 70 996/70 996, +# сделки 87 562/113 351) — выравниваем ПРОСТРАНСТВЕННО. +# • FLOOR по градусам — чистая арифметика по ST_X/ST_Y, без репроекций и без стыковки +# с админграницами, которых в БД нет. Точность границ ячейки здесь не важна — важно, +# что ОБЕ стороны режутся ОДИНАКОВО. +_CELL_LAT_DEG: float = 0.1 +_CELL_LON_DEG: float = 0.2 + +# Порог НА ЯЧЕЙКУ (все комнатности вместе) — сколько нужно, чтобы ячейка считалась +# покрытой ОБЕИМИ сторонами. Ниже глобального 30/30 НАМЕРЕННО: ячеечная медиана не +# публикуется сама по себе — она входит во взвешенную сумму, а публикуемый барьер +# остаётся прежним 30/30, но уже на СУММЕ по удержанным ячейкам (HAVING ниже). +_CELL_MIN_DEALS: int = 10 +_CELL_MIN_LISTINGS: int = 10 + +# Порог на пару (ячейка, бакет комнат) — ещё мягче: внутри уже отобранной ячейки +# комнатность дробит выборку ещё на 5 частей. Меньше 5 наблюдений на сторону — медиана +# шум, и при большом весе этот шум попадёт в итоговую строку. +_CELL_BUCKET_MIN_DEALS: int = 5 +_CELL_BUCKET_MIN_LISTINGS: int = 5 + +# ГАРДЫ ДЕГРАДАЦИИ (пункт 6 задачи): если согласованной географии по факту нет — +# лучше НЕ писать строку вообще (эстиматор деградирует явно, без коэффициента), +# чем посчитать неверно и выглядеть уверенно. +_MIN_MATCHED_CELLS: int = 3 +# Доля СДЕЛОК (с geom), попавших в пересечение ячеек. Именно сделки — целевая популяция +# (на их географию перевзвешивается ask-сторона); объявления за пределами пересечения +# отбрасываются НАМЕРЕННО (это и есть фикс), поэтому гарда на них нет — только счётчик. +_MIN_DEAL_CELL_COVERAGE: float = 0.5 +# Доля строк с geom на КАЖДОЙ стороне: если большая часть стороны без координат, +# выравнивать пространственно нечего — получился бы коэффициент по неслучайному остатку. +_MIN_GEOM_COVERAGE: float = 0.5 +# Сигнальный (не блокирующий) порог: выше него пишется WARNING. 0.25 выбран чуть выше +# текущего прод-состояния региона 50 (25 789/113 351 = 22.8% сделок ждут геокодера), +# чтобы лог не шумел на норме, но ухудшение было видно сразу. Доля попадает в счётчики +# ВСЕГДА, независимо от порога — строки без geom не выпадают молча (пункт 3 задачи). +_GEOM_WARN_SHARE: float = 0.25 + +# ОБЩИЕ ФИЛЬТРЫ сторон — один источник правды для stats- и insert-запросов (иначе счётчики +# и деривация разъехались бы при первой же правке одного из них). Состав гардов тот же, +# что у ЕКБ-деривации (12-мес окно, ppm²-полоса, свежесть #2656, novostroyki #1186, +# area_m2 IS NOT NULL #2620) — меняется ТОЛЬКО гео-согласование. +_DEAL_FROM_WHERE_REGION = """ + FROM deals + WHERE source = 'rosreestr' + AND rooms IS NOT NULL + AND region_code = CAST(:region_code AS int) + AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max + AND deal_date >= CURRENT_DATE - INTERVAL '12 months' +""" + +_ASK_FROM_WHERE_REGION = """ + FROM listings + WHERE is_active + AND scraped_at > NOW() - (:fresh_days || ' days')::interval + AND rooms IS NOT NULL + AND area_m2 IS NOT NULL + AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max + AND (listing_segment IS NULL OR listing_segment = 'vtorichka') + AND region_code = CAST(:region_code AS int) +""" + +# Ячеечные CTE — ОБЩИЕ для stats-запроса (счётчики + гард) и для самой деривации, +# чтобы решение «писать / не писать» принималось РОВНО по тем ячейкам, которые потом считаются. +_CELL_CTES_REGION = f""" + deal_geo AS ( + SELECT + FLOOR(ST_Y(geom) / {_CELL_LAT_DEG}) AS cell_lat, + FLOOR(ST_X(geom) / {_CELL_LON_DEG}) AS cell_lon, + LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket, + price_per_m2 + {_DEAL_FROM_WHERE_REGION} AND geom IS NOT NULL + ), + ask_geo AS ( + SELECT + FLOOR(ST_Y(geom) / {_CELL_LAT_DEG}) AS cell_lat, + FLOOR(ST_X(geom) / {_CELL_LON_DEG}) AS cell_lon, + {_AREA_ROOMS_BUCKET_SQL} AS rooms_bucket, + price_per_m2 + {_ASK_FROM_WHERE_REGION} AND geom IS NOT NULL + ), + deal_cell AS ( + SELECT + cell_lat, cell_lon, + percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median, + COUNT(*) AS n_deals + FROM deal_geo + GROUP BY cell_lat, cell_lon + ), + ask_cell AS ( + SELECT + cell_lat, cell_lon, + percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median, + COUNT(*) AS n_listings + FROM ask_geo + GROUP BY cell_lat, cell_lon + ), + -- СОГЛАСОВАННАЯ ГЕОГРАФИЯ: ячейки, где ОБЕ стороны имеют свою массу. + matched_cell AS ( + SELECT + d.cell_lat, d.cell_lon, + d.sold_median, d.n_deals, + a.ask_median, a.n_listings + FROM deal_cell d + JOIN ask_cell a USING (cell_lat, cell_lon) + WHERE d.n_deals >= {_CELL_MIN_DEALS} + AND a.n_listings >= {_CELL_MIN_LISTINGS} + AND d.sold_median IS NOT NULL AND d.sold_median > 0 + AND a.ask_median IS NOT NULL AND a.ask_median > 0 + )""" + +# Статистика СОСТАВА выборки — считается ДО деривации и решает, писать ли регион вообще. +# Строки БЕЗ geom тоже считаются (n_all vs n_geo) — они выпадают из деривации, и это +# должно быть видно в счётчиках, а не тихо (пункт 3 задачи). +_GEO_STATS_SQL_REGION = text( + f""" + WITH{_CELL_CTES_REGION}, + deal_all AS ( + SELECT + COUNT(*) AS n_all, + COUNT(*) FILTER (WHERE geom IS NOT NULL) AS n_geo + {_DEAL_FROM_WHERE_REGION} ), + ask_all AS ( + SELECT + COUNT(*) AS n_all, + COUNT(*) FILTER (WHERE geom IS NOT NULL) AS n_geo + {_ASK_FROM_WHERE_REGION} ) + SELECT + da.n_all AS deals_total, + da.n_geo AS deals_geo, + aa.n_all AS listings_total, + aa.n_geo AS listings_geo, + (SELECT COUNT(*) FROM deal_cell) AS cells_deal, + (SELECT COUNT(*) FROM ask_cell) AS cells_ask, + (SELECT COUNT(*) FROM deal_cell d + JOIN ask_cell a USING (cell_lat, cell_lon)) AS cells_both_sides, + (SELECT COUNT(*) FROM matched_cell) AS cells_matched, + (SELECT COALESCE(SUM(n_deals), 0) FROM matched_cell) AS deals_in_cells, + (SELECT COALESCE(SUM(n_listings), 0) FROM matched_cell) AS listings_in_cells + FROM deal_all da CROSS JOIN ask_all aa +""" +) + +# Деривация на согласованной географии. Отличий от ЕКБ-пути (_REDERIVE_SQL) ровно два: +# 1. городская квота ЕКБ → симметричный region_code на обеих сторонах (#3512); +# 2. медианы считаются ВНУТРИ ячейки и агрегируются с весами по числу сделок (#3529). +# Окно 12 мес, ppm²-полоса, area-бакет ask-стороны, порог 30/30 на публикуемую строку — прежние. _REDERIVE_SQL_REGION = text( f""" - WITH - deal_side AS ( + WITH{_CELL_CTES_REGION}, + -- Внутри УЖЕ отобранных ячеек — разрез по бакету комнат (обе стороны — тот же набор + -- ячеек, т.е. гео-ключ есть И в фильтре, И в соединении — в отличие от старого + -- `JOIN ... USING (rooms_bucket)`, где географии в соединении не было вообще). + deal_cell_bucket AS ( SELECT - LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket, - percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median, - COUNT(*) AS n_deals - FROM deals - WHERE source = 'rosreestr' - AND rooms IS NOT NULL - AND region_code = CAST(:region_code AS int) - AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max - AND deal_date >= CURRENT_DATE - INTERVAL '12 months' - GROUP BY LEAST(GREATEST(rooms, 0), 4) + g.cell_lat, g.cell_lon, g.rooms_bucket, + percentile_cont(0.5) WITHIN GROUP (ORDER BY g.price_per_m2) AS sold_median, + COUNT(*) AS n_deals + FROM deal_geo g + JOIN matched_cell m USING (cell_lat, cell_lon) + GROUP BY g.cell_lat, g.cell_lon, g.rooms_bucket ), - ask_side AS ( + ask_cell_bucket AS ( SELECT - {_AREA_ROOMS_BUCKET_SQL} AS rooms_bucket, - percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median, - COUNT(*) AS n_listings - FROM listings - WHERE is_active - AND scraped_at > NOW() - (:fresh_days || ' days')::interval - AND rooms IS NOT NULL - AND area_m2 IS NOT NULL - AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max - AND (listing_segment IS NULL OR listing_segment = 'vtorichka') - AND region_code = CAST(:region_code AS int) - GROUP BY {_AREA_ROOMS_BUCKET_SQL} + g.cell_lat, g.cell_lon, g.rooms_bucket, + percentile_cont(0.5) WITHIN GROUP (ORDER BY g.price_per_m2) AS ask_median, + COUNT(*) AS n_listings + FROM ask_geo g + JOIN matched_cell m USING (cell_lat, cell_lon) + GROUP BY g.cell_lat, g.cell_lon, g.rooms_bucket ), - per_bucket AS ( + bucket_cell AS ( SELECT d.rooms_bucket, - ''::text AS district, - (d.sold_median / a.ask_median)::numeric AS ratio, - round(d.sold_median)::bigint AS sold_median, - round(a.ask_median)::bigint AS ask_median, - d.n_deals::int AS n_deals, - a.n_listings::int AS n_listings, - 12 AS window_months, - 'per_rooms'::text AS basis - FROM deal_side d - JOIN ask_side a USING (rooms_bucket) - WHERE d.n_deals >= 30 - AND a.n_listings >= 30 - AND a.ask_median IS NOT NULL - AND a.ask_median > 0 - AND d.sold_median IS NOT NULL - AND d.sold_median > 0 + d.sold_median, d.n_deals, + a.ask_median, a.n_listings + FROM deal_cell_bucket d + JOIN ask_cell_bucket a USING (cell_lat, cell_lon, rooms_bucket) + WHERE d.n_deals >= {_CELL_BUCKET_MIN_DEALS} + AND a.n_listings >= {_CELL_BUCKET_MIN_LISTINGS} + AND d.sold_median IS NOT NULL AND d.sold_median > 0 + AND a.ask_median IS NOT NULL AND a.ask_median > 0 ), - deal_global AS ( + -- Взвешивание по числу СДЕЛОК: ask-сторона приводится к географии сделок. + -- ratio == sold_median/ask_median построчно (оба — взвешенные средние с ОДНИМИ весами), + -- так что публикуемые столбцы остаются взаимно согласованными. + per_bucket AS ( SELECT - percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median, - COUNT(*) AS n_deals - FROM deals - WHERE source = 'rosreestr' - AND rooms IS NOT NULL - AND region_code = CAST(:region_code AS int) - AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max - AND deal_date >= CURRENT_DATE - INTERVAL '12 months' - ), - ask_global AS ( - SELECT - percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median, - COUNT(*) AS n_listings - FROM listings - WHERE is_active - AND scraped_at > NOW() - (:fresh_days || ' days')::interval - AND rooms IS NOT NULL - AND area_m2 IS NOT NULL - AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max - AND (listing_segment IS NULL OR listing_segment = 'vtorichka') - AND region_code = CAST(:region_code AS int) + rooms_bucket, + ''::text AS district, + CAST(SUM(sold_median * n_deals) / SUM(ask_median * n_deals) + AS numeric) AS ratio, + round(SUM(sold_median * n_deals) / SUM(n_deals))::bigint AS sold_median, + round(SUM(ask_median * n_deals) / SUM(n_deals))::bigint AS ask_median, + SUM(n_deals)::int AS n_deals, + SUM(n_listings)::int AS n_listings, + 12 AS window_months, + 'per_rooms'::text AS basis + FROM bucket_cell + GROUP BY rooms_bucket + -- ТОТ ЖЕ публикуемый барьер 30/30, что и раньше — теперь на сумме по ячейкам. + HAVING SUM(n_deals) >= 30 + AND SUM(n_listings) >= 30 + AND SUM(ask_median * n_deals) > 0 ), + -- Global -1 fallback — те же ячейки, но без разреза по комнатности. global_row AS ( SELECT - -1 AS rooms_bucket, - ''::text AS district, - (d.sold_median / a.ask_median)::numeric AS ratio, - round(d.sold_median)::bigint AS sold_median, - round(a.ask_median)::bigint AS ask_median, - d.n_deals::int AS n_deals, - a.n_listings::int AS n_listings, - 12 AS window_months, - 'global_fallback'::text AS basis - FROM deal_global d - CROSS JOIN ask_global a - WHERE a.ask_median IS NOT NULL - AND a.ask_median > 0 - AND d.sold_median IS NOT NULL - AND d.sold_median > 0 + -1 AS rooms_bucket, + ''::text AS district, + CAST(SUM(sold_median * n_deals) / SUM(ask_median * n_deals) + AS numeric) AS ratio, + round(SUM(sold_median * n_deals) / SUM(n_deals))::bigint AS sold_median, + round(SUM(ask_median * n_deals) / SUM(n_deals))::bigint AS ask_median, + SUM(n_deals)::int AS n_deals, + SUM(n_listings)::int AS n_listings, + 12 AS window_months, + 'global_fallback'::text AS basis + FROM matched_cell + HAVING SUM(n_deals) > 0 + AND SUM(ask_median * n_deals) > 0 ) INSERT INTO asking_to_sold_ratios ( rooms_bucket, district, ratio, sold_median, ask_median, @@ -414,9 +569,93 @@ _REDERIVE_SQL_REGION = text( UNION ALL SELECT rooms_bucket, district, ratio, sold_median, ask_median, n_deals, n_listings, window_months, basis, CAST(:region_code AS int) FROM per_bucket - """ +""" ) +# КОЛОНКА district (#647-слот) ОСТАЁТСЯ ПУСТОЙ и здесь. Идентификатор ячейки в неё не +# ложится: ячейка — ПРОМЕЖУТОЧНАЯ единица расчёта, а не единица публикации. На выходе +# по-прежнему одна строка на (регион, бакет) — агрегат по всем ячейкам; записать в +# district «какую-то одну» ячейку было бы враньём, а писать строку НА ЯЧЕЙКУ нельзя: +# потребитель (estimator._get_asking_sold_ratio) читает строго `district = ''` и +# ключевать оценку по гео-ячейке пока не умеет — это отдельная задача #647. + + +def _pct(part: float, whole: float) -> int: + """Доля part/whole в ЦЕЛЫХ процентах (счётчики scrape_runs — dict[str, int]).""" + if whole <= 0: + return 0 + return round(100.0 * part / whole) + + +def geo_region_verdict(region_code: int, stats: dict[str, int] | None) -> tuple[bool, str]: + """Писать ли строки региона по согласованной географии (пункт 6 — явная деградация). + + Возвращает (ok, reason). ok=False — регион НЕ получает НИ ОДНОЙ строки (старые всё + равно удалены), и эстиматор честно остаётся без коэффициента вместо неверного. + Чистая функция от строки stats — тестируется без базы. + """ + if not stats: + return False, "geo-stats не вернулись" + deals_total = int(stats.get("deals_total") or 0) + deals_geo = int(stats.get("deals_geo") or 0) + listings_total = int(stats.get("listings_total") or 0) + listings_geo = int(stats.get("listings_geo") or 0) + cells_matched = int(stats.get("cells_matched") or 0) + deals_in_cells = int(stats.get("deals_in_cells") or 0) + + if deals_total == 0 or listings_total == 0: + return False, f"нет данных: deals={deals_total} listings={listings_total}" + if deals_geo / deals_total < _MIN_GEOM_COVERAGE: + return False, ( + f"сделки без geom: {_pct(deals_total - deals_geo, deals_total)}% " + f"(порог покрытия {_MIN_GEOM_COVERAGE:.0%})" + ) + if listings_geo / listings_total < _MIN_GEOM_COVERAGE: + return False, ( + f"объявления без geom: {_pct(listings_total - listings_geo, listings_total)}% " + f"(порог покрытия {_MIN_GEOM_COVERAGE:.0%})" + ) + if cells_matched < _MIN_MATCHED_CELLS: + return False, ( + f"ячеек с обеими сторонами {cells_matched} < {_MIN_MATCHED_CELLS} " + f"(географии сделок и объявлений практически не пересекаются)" + ) + if deals_geo > 0 and deals_in_cells / deals_geo < _MIN_DEAL_CELL_COVERAGE: + return False, ( + f"в пересечение ячеек попало {_pct(deals_in_cells, deals_geo)}% сделок " + f"(порог {_MIN_DEAL_CELL_COVERAGE:.0%})" + ) + _ = region_code + return True, "ok" + + +def _geo_region_counters(region_code: int, stats: dict[str, int] | None) -> dict[str, int]: + """Порегионные счётчики состава выборки (пункты 3 и 5 задачи), всё — int.""" + s = stats or {} + deals_total = int(s.get("deals_total") or 0) + deals_geo = int(s.get("deals_geo") or 0) + listings_total = int(s.get("listings_total") or 0) + listings_geo = int(s.get("listings_geo") or 0) + deals_in_cells = int(s.get("deals_in_cells") or 0) + listings_in_cells = int(s.get("listings_in_cells") or 0) + cells_matched = int(s.get("cells_matched") or 0) + cells_both = int(s.get("cells_both_sides") or 0) + p = f"geo_r{region_code}_" + return { + p + "cells_deal": int(s.get("cells_deal") or 0), + p + "cells_ask": int(s.get("cells_ask") or 0), + p + "cells_matched": cells_matched, + # Ячейки, где есть обе стороны, но одна из них тоньше порога ячейки. + p + "cells_dropped": max(cells_both - cells_matched, 0), + # Сколько массы осталось ЗА пределами пересечения (от строк с geom). + p + "deals_outside_pct": _pct(deals_geo - deals_in_cells, deals_geo), + p + "listings_outside_pct": _pct(listings_geo - listings_in_cells, listings_geo), + # Строки без координат — не выпадают молча (пункт 3). + p + "deals_no_geom_pct": _pct(deals_total - deals_geo, deals_total), + p + "listings_no_geom_pct": _pct(listings_total - listings_geo, listings_total), + } + + # ── Post-insert counters ────────────────────────────────────────────────────── # Считываем итог из таблицы (всё ещё в той же транзакции — до commit): сколько строк # записано всего, сколько per_rooms, был ли использован global -1 fallback. @@ -472,6 +711,10 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]: "rows_written": 0, "per_rooms_rows": 0, "used_global_fallback": 0, + # #3529: сколько регионов посчитано по согласованной географии, а сколько + # деградировало явно (строк нет → эстиматор без коэффициента). + "geo_regions_written": 0, + "geo_regions_skipped": 0, } try: # DELETE + re-derive INSERT в одной транзакции (НЕ коммитим между ними — @@ -488,15 +731,56 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]: }, ) for region_code in _OTHER_REGION_CODES: + params = { + "region_code": region_code, + "ppm2_min": _PPM2_MIN, + "ppm2_max": settings.asking_ratio_ppm2_max, + "fresh_days": LISTINGS_FRESH_DAYS, + } + # Сначала состав выборки (#3529) — он же решает, писать ли регион вообще. + stats_row = db.execute(_GEO_STATS_SQL_REGION, params).mappings().first() + stats = dict(stats_row) if stats_row is not None else None + counters.update(_geo_region_counters(region_code, stats)) + ok, reason = geo_region_verdict(region_code, stats) + + # DELETE идёт В ЛЮБОМ случае: если согласованной географии больше нет, старый + # (считанный по пулу) коэффициент тем более не должен оставаться в таблице. db.execute(_DELETE_SQL_REGION, {"region_code": region_code}) - db.execute( - _REDERIVE_SQL_REGION, - { - "region_code": region_code, - "ppm2_min": _PPM2_MIN, - "ppm2_max": settings.asking_ratio_ppm2_max, - "fresh_days": LISTINGS_FRESH_DAYS, - }, + + no_geom_deals = counters.get(f"geo_r{region_code}_deals_no_geom_pct", 0) + no_geom_listings = counters.get(f"geo_r{region_code}_listings_no_geom_pct", 0) + if max(no_geom_deals, no_geom_listings) >= int(_GEOM_WARN_SHARE * 100): + # Пункт 3: строки без координат не выпадают молча — это сигнал. + logger.warning( + "asking_to_sold_ratio region_code=%d: без geom сделок %d%%, " + "объявлений %d%% — гео-согласование считается по остатку", + region_code, + no_geom_deals, + no_geom_listings, + ) + + if not ok: + counters["geo_regions_skipped"] += 1 + counters[f"geo_r{region_code}_skipped"] = 1 + logger.warning( + "asking_to_sold_ratio region_code=%d: СТРОКИ НЕ ПИШУТСЯ — %s. " + "Оценка останется без коэффициента (явная деградация)", + region_code, + reason, + ) + continue + + counters[f"geo_r{region_code}_skipped"] = 0 + counters["geo_regions_written"] += 1 + db.execute(_REDERIVE_SQL_REGION, params) + logger.info( + "asking_to_sold_ratio region_code=%d: ячеек с обеими сторонами %d " + "(отброшено по порогу %d), вне пересечения: сделок %d%%, объявлений %d%%", + region_code, + counters.get(f"geo_r{region_code}_cells_matched", 0), + counters.get(f"geo_r{region_code}_cells_dropped", 0), + counters.get(f"geo_r{region_code}_deals_outside_pct", 0), + counters.get(f"geo_r{region_code}_listings_outside_pct", 0), ) row = db.execute(_COUNTERS_SQL).mappings().first() diff --git a/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py b/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py index 1889e7ab..1085ebd7 100644 --- a/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py +++ b/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py @@ -46,6 +46,8 @@ _ALL_SQL = ( + "\n" + _REDERIVE_SQL_REGION + "\n" + + str(ratio_mod._GEO_STATS_SQL_REGION.text) + + "\n" + _COUNTERS_SQL ) _TASK_SRC = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios) @@ -452,6 +454,22 @@ class _FakeDB: self.executed.append((stmt, params)) if stmt is ratio_mod._COUNTERS_SQL: return _FakeMappingResult(self._counters_row) + if stmt is ratio_mod._GEO_STATS_SQL_REGION: + # #3529: здоровый регион — гео-гард пропускает, деривация выполняется. + return _FakeMappingResult( + { + "deals_total": 1000, + "deals_geo": 950, + "listings_total": 2000, + "listings_geo": 2000, + "cells_deal": 20, + "cells_ask": 22, + "cells_both_sides": 18, + "cells_matched": 16, + "deals_in_cells": 900, + "listings_in_cells": 1500, + } + ) return _FakeMappingResult(None) def commit(self) -> None: @@ -485,14 +503,22 @@ def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None: "per_rooms_rows": 3, "used_global_fallback": 1, } - assert out == expected + for key, value in expected.items(): + assert out[key] == value assert db.committed is True - # EKB (DELETE+INSERT) + 2 per other region + 1 COUNTERS SELECT. - expected_calls = 2 + 2 * len(ratio_mod._OTHER_REGION_CODES) + 1 + # #3529: у каждого прочего региона теперь ТРИ стейтмента — geo-stats SELECT, + # DELETE и (если гард пропустил) INSERT; счётчики несут гео-разрез. + expected_calls = 2 + 3 * len(ratio_mod._OTHER_REGION_CODES) + 1 assert len(db.executed) == expected_calls assert db.executed[-1][0] is ratio_mod._COUNTERS_SQL + assert out["geo_regions_written"] == len(ratio_mod._OTHER_REGION_CODES) + assert out["geo_regions_skipped"] == 0 + for code in ratio_mod._OTHER_REGION_CODES: + assert out[f"geo_r{code}_cells_matched"] == 16 + assert out[f"geo_r{code}_deals_no_geom_pct"] == 5 + assert out[f"geo_r{code}_skipped"] == 0 assert marked["run_id"] == 99 - assert marked["counters"] == expected + assert marked["counters"] == out def test_counter_logic_failure_path_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None: @@ -628,13 +654,15 @@ def test_rederive_sql_region_scopes_by_region_code_not_city() -> None: ЕКБ-исторического asking-покрытия (#C2), не общим правилом (#3512). """ assert "city ILIKE" not in _REDERIVE_SQL_REGION - assert _REDERIVE_SQL_REGION.count("region_code = CAST(:region_code AS int)") >= 4 + # #3529: стороны собираются по одному разу (deal_geo / ask_geo), дальше всё считается + # по ячейкам — поэтому region_code-предикат ровно два раза, а не четыре. + assert _REDERIVE_SQL_REGION.count("region_code = CAST(:region_code AS int)") == 2 assert "FROM deals" in _REDERIVE_SQL_REGION assert "FROM listings" in _REDERIVE_SQL_REGION - # Тот же порог 30/30 и то же 12-мес окно, что и у ЕКБ-деривации (переиспользуется, - # не изобретается заново — требование задачи). - assert "d.n_deals >= 30" in _REDERIVE_SQL_REGION - assert "a.n_listings >= 30" in _REDERIVE_SQL_REGION + # Тот же публикуемый порог 30/30 и то же 12-мес окно, что и у ЕКБ-деривации — порог + # теперь применяется к СУММЕ по удержанным гео-ячейкам (#3529). + assert "HAVING SUM(n_deals) >= 30" in _REDERIVE_SQL_REGION + assert "AND SUM(n_listings) >= 30" in _REDERIVE_SQL_REGION assert "deal_date >= CURRENT_DATE - INTERVAL '12 months'" in _REDERIVE_SQL_REGION # INSERT пишет параметризованный регион, а не литерал 66. assert "CAST(:region_code AS int) FROM global_row" in _REDERIVE_SQL_REGION diff --git a/tradein-mvp/backend/tests/test_ratio_geography_match_3529.py b/tradein-mvp/backend/tests/test_ratio_geography_match_3529.py new file mode 100644 index 00000000..12a97f05 --- /dev/null +++ b/tradein-mvp/backend/tests/test_ratio_geography_match_3529.py @@ -0,0 +1,249 @@ +"""Гео-согласованный коэффициент asking→sold вне ЕКБ (#3529). + +Прод-замер (регион 50): пул даёт 0.891 при 0.73 у Москвы и 0.73 у ЕКБ, потому что обе +стороны фильтровались только по region_code и соединялись только по бакету комнат — +объявления смещены к дальней дешёвой периферии сильнее, чем сделки. Разложение по +кольцам 10 км показывает 0.808 / 0.847 / 0.951 / 0.688 ВНУТРИ колец, причём 76% сделок +лежат в ближних кольцах с 0.81-0.85. + +Тесты: (1) арифметика перекоса состава и то, что взвешивание по географии сделок его +снимает; (2) SQL реально соединяет стороны по гео-ключу; (3) путь ЕКБ не тронут; +(4) регион с непересекающейся географией деградирует (строк нет), а не пишет мусор. +""" + +import os +import re + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.tasks import asking_to_sold_ratio as ratio_mod + +_REGION_SQL = str(ratio_mod._REDERIVE_SQL_REGION.text) +_STATS_SQL = str(ratio_mod._GEO_STATS_SQL_REGION.text) +_EKB_SQL = str(ratio_mod._REDERIVE_SQL.text) + + +# ── 1. Синтетика: пул завышает, согласованная география — нет ───────────────── + +# Ячейки из прод-разложения области 50 по кольцам 10 км (сделки 12 мес vs активные +# объявления): (n_deals, sold_median, n_listings, ask_median). +_PROD_RINGS = [ + (12_189, 175_000, 23_292, 216_666), + (4_132, 147_026, 10_206, 173_684), + (1_432, 150_916, 5_243, 158_675), + (150, 84_390, 2_294, 122_727), +] + + +def _pooled_ratio(cells: list[tuple[int, int, int, int]]) -> float: + """СТАРАЯ схема: каждая сторона усредняется по СВОЕЙ популяции, общего ключа нет. + + Ровно то, что делал `FROM deal_side d JOIN ask_side a USING (rooms_bucket)` — + sold взвешен сделками, ask взвешен объявлениями, география не совпадает. + """ + sold = sum(n_d * s for n_d, s, _, _ in cells) / sum(n_d for n_d, _, _, _ in cells) + ask = sum(n_l * a for _, _, n_l, a in cells) / sum(n_l for _, _, n_l, _ in cells) + return sold / ask + + +def _geo_matched_ratio(cells: list[tuple[int, int, int, int]]) -> float: + """НОВАЯ схема: обе стороны взвешены ОДНИМИ весами — числом сделок ячейки.""" + w = [n_d for n_d, _, _, _ in cells] + sold = sum(n_d * s for n_d, s, _, _ in cells) / sum(w) + ask = sum(n_d * a for n_d, _, _, a in cells) / sum(w) + return sold / ask + + +def test_pool_inflates_and_geo_matching_fixes_it() -> None: + """Пул завышает коэффициент; та же выборка на согласованной географии — нет. + + Числа взяты из прод-разложения (см. _PROD_RINGS): внутри ячеек 0.69-0.95, а общий + пул — около 0.89. Перевзвешивание ask-стороны на географию сделок возвращает + результат в диапазон ближних колец, где лежит 76% сделок. + """ + pooled = _pooled_ratio(_PROD_RINGS) + matched = _geo_matched_ratio(_PROD_RINGS) + per_cell = [s / a for _, s, _, a in _PROD_RINGS] + + # Пул — завышен. NB: 0.858 здесь — НИЖНЯЯ оценка перекоса: пул восстановлен из + # ячеечных медиан взвешенными средними, тогда как реальный запрос брал МЕДИАНУ + # пулированной популяции, которая уезжает в дальнюю (объявлений там втрое больше + # сделок) периферию ещё глубже — прод показывал 0.891 при 0.73 у Москвы и ЕКБ. + assert pooled > 0.85 + # Согласованная география — внутри диапазона ячеечных отношений, ближе к ближним + # кольцам (0.81-0.85), где сосредоточена масса сделок. + assert min(per_cell) <= matched <= max(per_cell) + assert 0.80 < matched < 0.86 + # И это не косметика: даже по нижней оценке расхождение больше 3 процентных пунктов + # выкупной цены, по прод-замеру — около 7. + assert pooled - matched > 0.03 + + +def test_geo_matched_ratio_is_weighted_by_deals_not_listings() -> None: + """Вес ячейки — число СДЕЛОК: дальняя периферия с горой объявлений не перетягивает. + + Утраиваем объявления в самой дальней (дешёвой) ячейке — согласованный коэффициент + не двигается вообще, пул уезжает. + """ + skewed = [ + (n_d, s, n_l * 3 if i == len(_PROD_RINGS) - 1 else n_l, a) + for i, (n_d, s, n_l, a) in enumerate(_PROD_RINGS) + ] + assert _geo_matched_ratio(skewed) == _geo_matched_ratio(_PROD_RINGS) + assert _pooled_ratio(skewed) != _pooled_ratio(_PROD_RINGS) + + +def test_sql_implements_the_same_weighted_formula() -> None: + """Формула из теста выше — ровно то, что считает SQL (иначе двойник разъедется). + + ratio = Σ(w·sold) / Σ(w·ask), w = n_deals; оба медианных столбца — взвешенные + средние с ТЕМИ ЖЕ весами, поэтому ratio == sold_median/ask_median построчно. + """ + flat = re.sub(r"\s+", " ", _REGION_SQL) + ratio_expr = "CAST(SUM(sold_median * n_deals) / SUM(ask_median * n_deals) AS numeric)" + assert ratio_expr + " AS ratio" in flat + assert "round(SUM(sold_median * n_deals) / SUM(n_deals))::bigint AS sold_median" in flat + assert "round(SUM(ask_median * n_deals) / SUM(n_deals))::bigint AS ask_median" in flat + # Веса — сделки, не объявления. + assert "SUM(sold_median * n_listings)" not in flat + assert "SUM(ask_median * n_listings)" not in flat + + +# ── 2. Гео-ключ есть И в фильтре, И в соединении ────────────────────────────── + + +def test_region_sql_joins_both_sides_on_the_same_cells() -> None: + """Старое соединение было ТОЛЬКО по бакету комнат — гео-ключа в нём не было вовсе.""" + flat = re.sub(r"\s+", " ", _REGION_SQL) + # Ячейка строится ОДИНАКОВО с обеих сторон (одни и те же градусные шаги). + assert flat.count(f"FLOOR(ST_Y(geom) / {ratio_mod._CELL_LAT_DEG}) AS cell_lat") == 2 + assert flat.count(f"FLOOR(ST_X(geom) / {ratio_mod._CELL_LON_DEG}) AS cell_lon") == 2 + # Соединение сторон несёт гео-ключ. + assert "JOIN ask_cell a USING (cell_lat, cell_lon)" in flat + assert "JOIN ask_cell_bucket a USING (cell_lat, cell_lon, rooms_bucket)" in flat + # Прежних «слепых» соединений не осталось. + assert "JOIN ask_side a USING (rooms_bucket)" not in flat + assert "CROSS JOIN ask_global" not in flat + # Порог ячейки — на ОБЕИХ сторонах. + assert f"d.n_deals >= {ratio_mod._CELL_MIN_DEALS}" in flat + assert f"a.n_listings >= {ratio_mod._CELL_MIN_LISTINGS}" in flat + # Публикуемый барьер 30/30 сохранён (теперь на сумме по удержанным ячейкам). + assert "HAVING SUM(n_deals) >= 30 AND SUM(n_listings) >= 30" in flat + + +def test_region_sql_stays_psycopg_v3_safe_and_region_scoped() -> None: + for sql in (_REGION_SQL, _STATS_SQL): + assert re.search(r":[a-z_]+::[a-z]", sql) is None + assert "city ILIKE" not in sql + # Деривация собирает каждую сторону по разу; stats-запрос дополнительно считает + # ВСЕ строки региона (включая без geom) — отсюда четыре предиката вместо двух. + assert _REGION_SQL.count("region_code = CAST(:region_code AS int)") == 2 + assert _STATS_SQL.count("region_code = CAST(:region_code AS int)") == 4 + assert "CAST(:region_code AS int) FROM global_row" in _REGION_SQL + assert "CAST(:region_code AS int) FROM per_bucket" in _REGION_SQL + + +def test_district_column_stays_empty() -> None: + """Идентификатор ячейки в district НЕ пишется — ячейка промежуточная, не публикуемая. + + Потребитель (estimator._get_asking_sold_ratio) читает строго `district = ''`; строка + на выходе одна на (регион, бакет) — агрегат по всем ячейкам. + """ + assert _REGION_SQL.count("''::text") == 2 + assert "cell_lat AS district" not in _REGION_SQL + assert "AS district" in _REGION_SQL + + +# ── 3. Путь ЕКБ (66) не тронут ──────────────────────────────────────────────── + + +def test_ekb_path_untouched_by_geo_matching() -> None: + """Регион 66 обязан остаться байт-в-байт прежним — там своя калибровка (#C2/#2583).""" + assert "cell_lat" not in _EKB_SQL + assert "ST_X(" not in _EKB_SQL and "ST_Y(" not in _EKB_SQL + assert _EKB_SQL.count("AND city ILIKE :asking_city") == 2 + assert _EKB_SQL.count("AND (city IS NULL OR city ILIKE :asking_city)") == 2 + assert "FROM deal_side d\n JOIN ask_side a USING (rooms_bucket)" in _EKB_SQL + assert "CROSS JOIN ask_global a" in _EKB_SQL + assert "basis, 66 FROM global_row" in _EKB_SQL + # Гео-статистика считается только для прочих регионов — 66 её не касается. + assert "_GEO_STATS_SQL_REGION" in ratio_mod.__dict__ or True + assert 66 not in ratio_mod._OTHER_REGION_CODES + + +# ── 4. Гард: непересекающаяся география → деградация, а не мусор ────────────── + + +def _stats(**over: int) -> dict[str, int]: + base = { + "deals_total": 1000, + "deals_geo": 900, + "listings_total": 2000, + "listings_geo": 2000, + "cells_deal": 20, + "cells_ask": 25, + "cells_both_sides": 18, + "cells_matched": 15, + "deals_in_cells": 800, + "listings_in_cells": 1200, + } + base.update(over) + return base + + +def test_verdict_ok_on_healthy_region() -> None: + ok, reason = ratio_mod.geo_region_verdict(50, _stats()) + assert ok is True + assert reason == "ok" + + +def test_verdict_degrades_on_disjoint_geography() -> None: + """Сделки и объявления в разных местах — регион не получает НИ ОДНОЙ строки.""" + ok, reason = ratio_mod.geo_region_verdict(50, _stats(cells_matched=1, deals_in_cells=30)) + assert ok is False + assert "не пересекаются" in reason + + # Пересечение есть, но в него попала меньшая часть сделок — тоже деградация. + ok, reason = ratio_mod.geo_region_verdict(50, _stats(deals_in_cells=100)) + assert ok is False + assert "11% сделок" in reason + + +def test_verdict_degrades_when_a_side_has_no_coordinates() -> None: + ok, reason = ratio_mod.geo_region_verdict(50, _stats(deals_geo=100)) + assert ok is False + assert "сделки без geom" in reason + ok, reason = ratio_mod.geo_region_verdict(50, _stats(listings_geo=10)) + assert ok is False + assert "объявления без geom" in reason + ok, reason = ratio_mod.geo_region_verdict(50, None) + assert ok is False + + +def test_verdict_degrades_on_empty_region() -> None: + ok, _ = ratio_mod.geo_region_verdict(50, _stats(deals_total=0, deals_geo=0)) + assert ok is False + + +def test_counters_expose_composition_and_missing_geom() -> None: + """Пункты 3 и 5: строки без geom и масса вне пересечения — в счётчиках, не в тишине.""" + c = ratio_mod._geo_region_counters(50, _stats()) + assert c["geo_r50_cells_matched"] == 15 + assert c["geo_r50_cells_dropped"] == 3 # есть обе стороны, но одна тоньше порога + assert c["geo_r50_deals_no_geom_pct"] == 10 # 100 из 1000 сделок без координат + assert c["geo_r50_listings_no_geom_pct"] == 0 + assert c["geo_r50_deals_outside_pct"] == 11 # 100 из 900 геокодированных сделок + assert c["geo_r50_listings_outside_pct"] == 40 # дальняя периферия отброшена намеренно + assert all(isinstance(v, int) for v in c.values()) + + +def test_skipped_region_still_deletes_stale_rows() -> None: + """DELETE идёт ДО решения писать/не писать — старый пуловый коэффициент не остаётся.""" + import inspect + + body = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios) + del_pos = body.index('db.execute(_DELETE_SQL_REGION, {"region_code": region_code})') + guard_pos = body.index("if not ok:") + insert_pos = body.index("db.execute(_REDERIVE_SQL_REGION, params)") + assert del_pos < guard_pos < insert_pos + assert "continue" in body[guard_pos:insert_pos]