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
Обе стороны раскладываются по одной сетке 0.1°x0.2°, медианы внутри ячейки, в итог только ячейки с ≥10 сделок И ≥10 объявлений, агрегация взвешена числом сделок. Замер на проде (SQL исполнен в транзакции с ROLLBACK): регион 50 — 0.8136 вместо пулового 0.8851, регион 77 — 0.7287 вместо 0.7100. Разнонаправленный сдвиг — подпись поправки состава. Регион 66 не тронут байт в байт. Гарды: регион не получает строк при покрытии geom <50%, совпавших ячейках <3 или попадании в пересечение <50% геокодированных сделок; DELETE идёт в любом случае.
249 lines
14 KiB
Python
249 lines
14 KiB
Python
"""Гео-согласованный коэффициент 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]
|