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
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:
parent
dbf46228fb
commit
1c714aeb88
3 changed files with 665 additions and 104 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
249
tradein-mvp/backend/tests/test_ratio_geography_match_3529.py
Normal file
249
tradein-mvp/backend/tests/test_ratio_geography_match_3529.py
Normal 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]
|
||||
Loading…
Add table
Reference in a new issue