gendesign/tradein-mvp/backend/tests/test_ratio_geography_match_3529.py
lekss361 39996ef3fd
All checks were successful
Deploy Trade-In / changes (push) Successful in 18s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m58s
Deploy Trade-In / build-backend (push) Successful in 1m25s
Deploy Trade-In / deploy (push) Successful in 2m9s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
Коэффициент asking→sold вне ЕКБ считается на согласованной географии (#3531)
Обе стороны раскладываются по одной сетке 0.1°x0.2°, медианы внутри ячейки, в итог
только ячейки с ≥10 сделок И ≥10 объявлений, агрегация взвешена числом сделок.

Замер на проде (SQL исполнен в транзакции с ROLLBACK): регион 50 — 0.8136 вместо
пулового 0.8851, регион 77 — 0.7287 вместо 0.7100. Разнонаправленный сдвиг — подпись
поправки состава. Регион 66 не тронут байт в байт.

Гарды: регион не получает строк при покрытии geom <50%, совпавших ячейках <3 или
попадании в пересечение <50% геокодированных сделок; DELETE идёт в любом случае.
2026-09-15 17:18:05 +00:00

249 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Гео-согласованный коэффициент 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]