fix(tradein): считать asking→sold коэффициент на согласованной географии вне ЕКБ
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m27s

Регионы кроме 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).
This commit is contained in:
bot-backend 2026-09-15 19:41:22 +03:00
parent dbf46228fb
commit 1c714aeb88
3 changed files with 665 additions and 104 deletions

View file

@ -305,105 +305,260 @@ _REDERIVE_SQL = text(
""" """
) )
# ── Generic per-region derivation (#3512) ───────────────────────────────────── # ── Geography-matched per-region derivation (#3529) ───────────────────────────
# Байт-в-байт та же деривация, что _REDERIVE_SQL выше (тот же 12-мес трейлинг, # ПРОБЛЕМА (#3512-путь, прод-замер 2026-09 по региону 50): обе стороны фильтровались
# ppm²-полоса, novostroyki/freshness/area_m2-гарды, area-бакет ask-стороны, порог # ТОЛЬКО по region_code и соединялись ТОЛЬКО по бакету комнат — т.е. sold-медиана и
# 30/30, global -1 fallback) — ЕДИНСТВЕННОЕ отличие: городская квота ЕКБ (city ILIKE / # ask-медиана считались по РАЗНЫМ географическим популяциям одного региона.
# city IS NULL OR city ILIKE) заменена на симметричный `region_code = :region_code` # Разложение обл.50 по кольцам 10 км от центра Москвы (сделки 12 мес vs активные объявления):
# на ОБЕИХ сторонах. Городской квоты здесь не было и не нужно: она была костылём # 20-30 км: 12 189 сделок / 23 292 объявления → 0.808
# конкретно исторического asking-покрытия ЕКБ (#C2), а не общим правилом. # 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( _REDERIVE_SQL_REGION = text(
f""" f"""
WITH WITH{_CELL_CTES_REGION},
deal_side AS ( -- Внутри УЖЕ отобранных ячеек разрез по бакету комнат (обе стороны тот же набор
-- ячеек, т.е. гео-ключ есть И в фильтре, И в соединении в отличие от старого
-- `JOIN ... USING (rooms_bucket)`, где географии в соединении не было вообще).
deal_cell_bucket AS (
SELECT SELECT
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket, g.cell_lat, g.cell_lon, g.rooms_bucket,
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median, percentile_cont(0.5) WITHIN GROUP (ORDER BY g.price_per_m2) AS sold_median,
COUNT(*) AS n_deals COUNT(*) AS n_deals
FROM deals FROM deal_geo g
WHERE source = 'rosreestr' JOIN matched_cell m USING (cell_lat, cell_lon)
AND rooms IS NOT NULL GROUP BY g.cell_lat, g.cell_lon, g.rooms_bucket
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)
), ),
ask_side AS ( ask_cell_bucket AS (
SELECT SELECT
{_AREA_ROOMS_BUCKET_SQL} AS rooms_bucket, g.cell_lat, g.cell_lon, g.rooms_bucket,
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median, percentile_cont(0.5) WITHIN GROUP (ORDER BY g.price_per_m2) AS ask_median,
COUNT(*) AS n_listings COUNT(*) AS n_listings
FROM listings FROM ask_geo g
WHERE is_active JOIN matched_cell m USING (cell_lat, cell_lon)
AND scraped_at > NOW() - (:fresh_days || ' days')::interval GROUP BY g.cell_lat, g.cell_lon, g.rooms_bucket
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}
), ),
per_bucket AS ( bucket_cell AS (
SELECT SELECT
d.rooms_bucket, d.rooms_bucket,
''::text AS district, d.sold_median, d.n_deals,
(d.sold_median / a.ask_median)::numeric AS ratio, a.ask_median, a.n_listings
round(d.sold_median)::bigint AS sold_median, FROM deal_cell_bucket d
round(a.ask_median)::bigint AS ask_median, JOIN ask_cell_bucket a USING (cell_lat, cell_lon, rooms_bucket)
d.n_deals::int AS n_deals, WHERE d.n_deals >= {_CELL_BUCKET_MIN_DEALS}
a.n_listings::int AS n_listings, AND a.n_listings >= {_CELL_BUCKET_MIN_LISTINGS}
12 AS window_months, AND d.sold_median IS NOT NULL AND d.sold_median > 0
'per_rooms'::text AS basis AND a.ask_median IS NOT NULL AND a.ask_median > 0
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
), ),
deal_global AS ( -- Взвешивание по числу СДЕЛОК: ask-сторона приводится к географии сделок.
-- ratio == sold_median/ask_median построчно (оба взвешенные средние с ОДНИМИ весами),
-- так что публикуемые столбцы остаются взаимно согласованными.
per_bucket AS (
SELECT SELECT
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median, rooms_bucket,
COUNT(*) AS n_deals ''::text AS district,
FROM deals CAST(SUM(sold_median * n_deals) / SUM(ask_median * n_deals)
WHERE source = 'rosreestr' AS numeric) AS ratio,
AND rooms IS NOT NULL round(SUM(sold_median * n_deals) / SUM(n_deals))::bigint AS sold_median,
AND region_code = CAST(:region_code AS int) round(SUM(ask_median * n_deals) / SUM(n_deals))::bigint AS ask_median,
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max SUM(n_deals)::int AS n_deals,
AND deal_date >= CURRENT_DATE - INTERVAL '12 months' SUM(n_listings)::int AS n_listings,
), 12 AS window_months,
ask_global AS ( 'per_rooms'::text AS basis
SELECT FROM bucket_cell
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median, GROUP BY rooms_bucket
COUNT(*) AS n_listings -- ТОТ ЖЕ публикуемый барьер 30/30, что и раньше теперь на сумме по ячейкам.
FROM listings HAVING SUM(n_deals) >= 30
WHERE is_active AND SUM(n_listings) >= 30
AND scraped_at > NOW() - (:fresh_days || ' days')::interval AND SUM(ask_median * n_deals) > 0
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)
), ),
-- Global -1 fallback те же ячейки, но без разреза по комнатности.
global_row AS ( global_row AS (
SELECT SELECT
-1 AS rooms_bucket, -1 AS rooms_bucket,
''::text AS district, ''::text AS district,
(d.sold_median / a.ask_median)::numeric AS ratio, CAST(SUM(sold_median * n_deals) / SUM(ask_median * n_deals)
round(d.sold_median)::bigint AS sold_median, AS numeric) AS ratio,
round(a.ask_median)::bigint AS ask_median, round(SUM(sold_median * n_deals) / SUM(n_deals))::bigint AS sold_median,
d.n_deals::int AS n_deals, round(SUM(ask_median * n_deals) / SUM(n_deals))::bigint AS ask_median,
a.n_listings::int AS n_listings, SUM(n_deals)::int AS n_deals,
12 AS window_months, SUM(n_listings)::int AS n_listings,
'global_fallback'::text AS basis 12 AS window_months,
FROM deal_global d 'global_fallback'::text AS basis
CROSS JOIN ask_global a FROM matched_cell
WHERE a.ask_median IS NOT NULL HAVING SUM(n_deals) > 0
AND a.ask_median > 0 AND SUM(ask_median * n_deals) > 0
AND d.sold_median IS NOT NULL
AND d.sold_median > 0
) )
INSERT INTO asking_to_sold_ratios ( INSERT INTO asking_to_sold_ratios (
rooms_bucket, district, ratio, sold_median, ask_median, rooms_bucket, district, ratio, sold_median, ask_median,
@ -414,9 +569,93 @@ _REDERIVE_SQL_REGION = text(
UNION ALL UNION ALL
SELECT rooms_bucket, district, ratio, sold_median, ask_median, SELECT rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis, CAST(:region_code AS int) FROM per_bucket 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 ────────────────────────────────────────────────────── # ── Post-insert counters ──────────────────────────────────────────────────────
# Считываем итог из таблицы (всё ещё в той же транзакции — до commit): сколько строк # Считываем итог из таблицы (всё ещё в той же транзакции — до commit): сколько строк
# записано всего, сколько per_rooms, был ли использован global -1 fallback. # записано всего, сколько 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, "rows_written": 0,
"per_rooms_rows": 0, "per_rooms_rows": 0,
"used_global_fallback": 0, "used_global_fallback": 0,
# #3529: сколько регионов посчитано по согласованной географии, а сколько
# деградировало явно (строк нет → эстиматор без коэффициента).
"geo_regions_written": 0,
"geo_regions_skipped": 0,
} }
try: try:
# DELETE + re-derive INSERT в одной транзакции (НЕ коммитим между ними — # 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: 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(_DELETE_SQL_REGION, {"region_code": region_code})
db.execute(
_REDERIVE_SQL_REGION, 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)
"region_code": region_code, if max(no_geom_deals, no_geom_listings) >= int(_GEOM_WARN_SHARE * 100):
"ppm2_min": _PPM2_MIN, # Пункт 3: строки без координат не выпадают молча — это сигнал.
"ppm2_max": settings.asking_ratio_ppm2_max, logger.warning(
"fresh_days": LISTINGS_FRESH_DAYS, "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() row = db.execute(_COUNTERS_SQL).mappings().first()

View file

@ -46,6 +46,8 @@ _ALL_SQL = (
+ "\n" + "\n"
+ _REDERIVE_SQL_REGION + _REDERIVE_SQL_REGION
+ "\n" + "\n"
+ str(ratio_mod._GEO_STATS_SQL_REGION.text)
+ "\n"
+ _COUNTERS_SQL + _COUNTERS_SQL
) )
_TASK_SRC = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios) _TASK_SRC = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios)
@ -452,6 +454,22 @@ class _FakeDB:
self.executed.append((stmt, params)) self.executed.append((stmt, params))
if stmt is ratio_mod._COUNTERS_SQL: if stmt is ratio_mod._COUNTERS_SQL:
return _FakeMappingResult(self._counters_row) 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) return _FakeMappingResult(None)
def commit(self) -> None: def commit(self) -> None:
@ -485,14 +503,22 @@ def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None:
"per_rooms_rows": 3, "per_rooms_rows": 3,
"used_global_fallback": 1, "used_global_fallback": 1,
} }
assert out == expected for key, value in expected.items():
assert out[key] == value
assert db.committed is True assert db.committed is True
# EKB (DELETE+INSERT) + 2 per other region + 1 COUNTERS SELECT. # #3529: у каждого прочего региона теперь ТРИ стейтмента — geo-stats SELECT,
expected_calls = 2 + 2 * len(ratio_mod._OTHER_REGION_CODES) + 1 # DELETE и (если гард пропустил) INSERT; счётчики несут гео-разрез.
expected_calls = 2 + 3 * len(ratio_mod._OTHER_REGION_CODES) + 1
assert len(db.executed) == expected_calls assert len(db.executed) == expected_calls
assert db.executed[-1][0] is ratio_mod._COUNTERS_SQL 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["run_id"] == 99
assert marked["counters"] == expected assert marked["counters"] == out
def test_counter_logic_failure_path_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None: 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). ЕКБ-исторического asking-покрытия (#C2), не общим правилом (#3512).
""" """
assert "city ILIKE" not in _REDERIVE_SQL_REGION 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 deals" in _REDERIVE_SQL_REGION
assert "FROM listings" in _REDERIVE_SQL_REGION assert "FROM listings" in _REDERIVE_SQL_REGION
# Тот же порог 30/30 и то же 12-мес окно, что и у ЕКБ-деривации (переиспользуется, # Тот же публикуемый порог 30/30 и то же 12-мес окно, что и у ЕКБ-деривации — порог
# не изобретается заново — требование задачи). # теперь применяется к СУММЕ по удержанным гео-ячейкам (#3529).
assert "d.n_deals >= 30" in _REDERIVE_SQL_REGION assert "HAVING SUM(n_deals) >= 30" in _REDERIVE_SQL_REGION
assert "a.n_listings >= 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 assert "deal_date >= CURRENT_DATE - INTERVAL '12 months'" in _REDERIVE_SQL_REGION
# INSERT пишет параметризованный регион, а не литерал 66. # INSERT пишет параметризованный регион, а не литерал 66.
assert "CAST(:region_code AS int) FROM global_row" in _REDERIVE_SQL_REGION assert "CAST(:region_code AS int) FROM global_row" in _REDERIVE_SQL_REGION

View file

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