diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index de52a353..f3588104 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -30,6 +30,20 @@ _AUTH_DB_DEFAULT_NAME = "auth" # Роль приложения из data/sql/auth/002_auth_app_role.sql (least privilege). _AUTH_DB_DEFAULT_USER = "auth_app" +# ── Окно свежести объявлений (#2656) ────────────────────────────────────────── +# ЕДИНСТВЕННОЕ место, где живёт это число. Читают: estimator (_COMMON_WHERE +# радиусного пути, inline-копия Tier W, оба SQL якоря дома) и ночной пересчёт +# asking_to_sold_ratios (знаменатель коэффициента выкупа). Лежит здесь, а не в +# estimator.py, потому что estimator сам импортирует area_bucket из +# app.tasks.asking_to_sold_ratio — обратный импорт дал бы цикл. +# +# ЗАЧЕМ фильтр вообще: `is_active` означает РАЗНОЕ для разных источников (TTL +# деактивации 30 дней у cian/yandex-вторички, NULL-сегмент не деактивируется +# никогда), а `scraped_at` — одно и то же. Ослабление этого окна или подмена +# `scraped_at` на `last_seen_at` впускает в ценовые выборки объявления, которых +# никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк). +LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 0f9d4fea..dfeb1317 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -54,7 +54,7 @@ from scraper_kit.providers.yandex.valuation import ( from sqlalchemy import text from sqlalchemy.orm import Session -from app.core.config import Settings, settings +from app.core.config import LISTINGS_FRESH_DAYS, Settings, settings from app.core.db import SessionLocal from app.schemas.trade_in import ( AggregatedEstimate, @@ -92,7 +92,9 @@ FALLBACK_RADIUS_M = 2000 AREA_TOLERANCE = 0.15 # ±15% площади MAX_ANALOGS_PER_ADDRESS = 5 # анти-bias: не больше 5 лотов с одного адреса MIN_ANALOGS_PER_SOURCE = 5 # гарантированный минимум на live source -LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней +# LISTINGS_FRESH_DAYS (окно свежести объявлений) живёт в app.core.config — #2656: +# его читает и ночной пересчёт asking_to_sold_ratios, а импортировать estimator +# оттуда нельзя (estimator сам тянет area_bucket из этой задачи → цикл импорта). DEALS_PERIOD_MONTHS = 12 # сделки за последний год # ── ЭТАП 4 B2C launch — anonymous consent-before-save (152-ФЗ) ──────────────── @@ -2229,7 +2231,9 @@ def _fetch_anchor_comps( ненадёжны.) Tier D — фолбэк: None tier (caller остаётся на радиусном median-пути). - Excludes lots без price_per_m2. is_active=true. Best-effort: ([], None) на ошибке. + Excludes lots без price_per_m2. is_active=true И scraped_at свежее + LISTINGS_FRESH_DAYS (#2656 — то же окно, что у радиусного пути; см. + комментарий в SQL Tier A). Best-effort: ([], None) на ошибке. Returns (comps, tier) где tier ∈ {'A','C', None}. comps — list dict с ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None), @@ -2255,6 +2259,15 @@ def _fetch_anchor_comps( listing_segment, source_id FROM listings WHERE is_active = true + -- #2656: то же окно свежести, что несёт _COMMON_WHERE + -- радиусного пути. Без него якорь строился на + -- объявлениях, которых никто не видел месяц (is_active + -- живёт по TTL деактивации 30д, у NULL-сегмента — вообще + -- бессрочно), и они ЗАМЕЩАЛИ headline (median_ppm2/ + -- median_price/n_analogs ниже), т.е. двигали деньги + -- напрямую. Прод 2026-08: 31 дом из 952 получал якорь + -- Tier A исключительно за счёт протухших строк. + AND scraped_at > NOW() - (:fresh_days || ' days')::interval AND price_per_m2 > 0 AND lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like AND lower(translate(address, 'ёЁ', 'ее')) ~ :house_re @@ -2272,6 +2285,7 @@ def _fetch_anchor_comps( "lon": lon, "lat": lat, "radius": ANCHOR_TIER_A_RADIUS_M, + "fresh_days": LISTINGS_FRESH_DAYS, }, ) .mappings() @@ -2347,6 +2361,9 @@ def _fetch_anchor_comps( days_on_market, photo_urls, lat, lon FROM listings WHERE is_active = true + -- #2656: окно свежести, симметрично Tier A выше и + -- _COMMON_WHERE радиусного пути (см. комментарий там). + AND scraped_at > NOW() - (:fresh_days || ' days')::interval AND price_per_m2 > 0 AND rooms = CAST(:rooms AS integer) AND area_m2 BETWEEN :area_min AND :area_max @@ -2366,6 +2383,7 @@ def _fetch_anchor_comps( "area_max": area * 1.25, "lon": lon, "lat": lat, + "fresh_days": LISTINGS_FRESH_DAYS, }, ) .mappings() diff --git a/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py b/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py index e6220687..de5d8300 100644 --- a/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py +++ b/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py @@ -26,7 +26,14 @@ ask_side / per_bucket + deal_global / ask_global / global_row: трейлинг- n_listings>=30 для per_rooms, global -1 строка всегда). ON CONFLICT убран — DELETE идёт первым, конфликтов нет (повторный прогон в одной tx невозможен, refresh = re-seed по семантике). -#2620 — ОДНО ПРЕДНАМЕРЕННОЕ РАСХОЖДЕНИЕ с 080: deal_side бакетится по +#2656 — ВТОРОЕ ПРЕДНАМЕРЕННОЕ РАСХОЖДЕНИЕ с 080 (первое — #2620 ниже): ask_side/ask_global +несут фильтр свежести `scraped_at > NOW() - LISTINGS_FRESH_DAYS дней` — тот же, что эстиматор +применяет к ЧИСЛИТЕЛЮ (_COMMON_WHERE). Без него знаменатель считался по бессрочной популяции +объявлений, а числитель — по 14-дневной, т.е. коэффициент калибровался на одном рынке, а +применялся к другому. Замер на проде (2026-08, #2656): глобальный коэффициент −0.49%, бакеты +44-62 +2.43% и 62-85 +4.58%; NULL-сегмент в знаменателе схлопывается с 674 строк до 20. + +#2620 — ПЕРВОЕ ПРЕДНАМЕРЕННОЕ РАСХОЖДЕНИЕ с 080: deal_side бакетится по LEAST(GREATEST(rooms,0),4), а ask_side — по _AREA_ROOMS_BUCKET_SQL (площадь, та же формула, что deals.rooms получает при импорте). Причина — deals.rooms НЕ настоящая комнатность (Росреестр её не отдаёт), это синтетика из площади; сравнивать её с РЕАЛЬНЫМИ комнатами @@ -43,7 +50,7 @@ import logging from sqlalchemy import text from sqlalchemy.orm import Session -from app.core.config import settings +from app.core.config import LISTINGS_FRESH_DAYS, settings from app.services import scrape_runs as runs_mod # Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется. @@ -119,7 +126,8 @@ _DELETE_SQL = text( # бакет LEAST(GREATEST(rooms,0),4) (rooms уже синтетика-из-площади при импорте, см. #2620 # комментарий у _AREA_ROOMS_BUCKET_SQL выше). # ask_median = percentile_cont(0.5) по listings.price_per_m2 -# (is_active, та же ppm²-полоса [_PPM2_MIN, asking_ratio_ppm2_max], тот же город что +# (is_active + свежесть scraped_at ≤ LISTINGS_FRESH_DAYS (#2656), та же ppm²-полоса +# [_PPM2_MIN, asking_ratio_ppm2_max], тот же город что # SOLD-сторона — city IS NULL OR city ILIKE :asking_city, #2583 H2). Бакет — # _AREA_ROOMS_BUCKET_SQL (площадь, #2620), НЕ listings.rooms — см. комментарий там. # per_rooms строки — только при n_deals>=30 AND n_listings>=30 AND ask>0 AND sold>0. @@ -156,6 +164,13 @@ _REDERIVE_SQL = text( COUNT(*) AS n_listings FROM listings WHERE is_active + -- #2656: окно свежести — то же самое, что эстиматор применяет к ЧИСЛИТЕЛЮ + -- (_COMMON_WHERE, LISTINGS_FRESH_DAYS). Без него числитель оценки считался + -- по 14-дневной популяции, а знаменатель коэффициента — по бессрочной: + -- калибровка и применение по разным рынкам. `is_active` для этого не годится + -- — он означает разное у разных источников (TTL деактивации 30д, NULL-сегмент + -- не деактивируется никогда: 97.4% таких строк протухшие и при этом дорогие). + AND scraped_at > NOW() - (:fresh_days || ' days')::interval AND rooms IS NOT NULL -- #2620 hardening: area_m2 IS NULL falls into the CASE ELSE branch (bucket 4) -- of _AREA_ROOMS_BUCKET_SQL — a latent "everything unmeasured looks like a big @@ -216,6 +231,9 @@ _REDERIVE_SQL = text( COUNT(*) AS n_listings FROM listings WHERE is_active + -- #2656: то же окно свежести, что и в ask_side выше (см. комментарий там) + -- — global-строка должна считаться по той же популяции, что per-bucket. + AND scraped_at > NOW() - (:fresh_days || ' days')::interval AND rooms IS NOT NULL -- #2620 hardening: same area_m2 IS NOT NULL as ask_side — keeps the global-row -- population consistent with the per-bucket rows it's a fallback for. @@ -313,6 +331,7 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]: "ppm2_min": _PPM2_MIN, "ppm2_max": settings.asking_ratio_ppm2_max, "asking_city": _ASKING_CITY_PATTERN, + "fresh_days": LISTINGS_FRESH_DAYS, }, ) diff --git a/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py b/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py index b12d02a5..7879c7e7 100644 --- a/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py +++ b/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py @@ -208,6 +208,11 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None: #2620 hardening: the refresh also adds `AND area_m2 IS NOT NULL` to ask_side/ask_global (NULL area_m2 would fall into the CASE ELSE branch = bucket 4 — a latent trap). Absent in the 080 seed; dropped here the same way as the other guards above. + + #2656: the refresh now also adds the freshness predicate (`scraped_at > NOW() - + LISTINGS_FRESH_DAYS days`) to each ask_* CTE — the same window the estimator applies to + the NUMERATOR (_COMMON_WHERE). Absent in the 080 seed; normalised away here, asserted + on its own in tests/test_freshness_filter_2656.py. """ seed_sql = _MIGRATION_080.read_text("utf-8") # Extract the WITH … (up to the ON CONFLICT) from the seed. @@ -269,11 +274,17 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None: """ return re.sub(r"AND\s+area_m2\s+IS\s+NOT\s+NULL", "", s) + def _drop_freshness_guard(s: str) -> str: + """Remove the #2656 freshness predicate (absent in the 080 seed).""" + return re.sub(r"AND\s+scraped_at\s*>\s*NOW\(\)\s*-\s*\(:fresh_days[^\n]*?interval", "", s) + def _norm(s: str) -> str: return _strip_sql( _normalise_ppm2( _normalise_bucket_expr( - _drop_area_not_null_guard(_drop_city_guard(_drop_segment_guard(s))) + _drop_freshness_guard( + _drop_area_not_null_guard(_drop_city_guard(_drop_segment_guard(s))) + ) ) ) ) diff --git a/tradein-mvp/backend/tests/test_freshness_filter_2656.py b/tradein-mvp/backend/tests/test_freshness_filter_2656.py new file mode 100644 index 00000000..999f2ffd --- /dev/null +++ b/tradein-mvp/backend/tests/test_freshness_filter_2656.py @@ -0,0 +1,350 @@ +"""#2656 — фильтр свежести объявлений там, где он двигает деньги. + +Дыра (найдена аудитом 2026-08, код читан на origin/main): главный радиусный путь +эстиматора нёс `scraped_at > NOW() - LISTINGS_FRESH_DAYS дней` (_COMMON_WHERE), а +ЧЕТЫРЕ другие выборки, которые тоже двигают цену, — нет: + + 1. `_fetch_anchor_comps` Tier A (same building) — якорь ЗАМЕЩАЕТ headline + (median_ppm2/median_price/n_analogs), т.е. правит цену напрямую; + 2. `_fetch_anchor_comps` Tier C (микро-радиус ≤500 м) — то же замещение; + 3. `asking_to_sold_ratio.ask_side` — знаменатель коэффициента выкупа; + 4. `asking_to_sold_ratio.ask_global` — он же, global-фолбэк. + +`is_active` не заменяет свежесть: он означает РАЗНОЕ у разных источников (TTL +деактивации 30 дней у cian/yandex-вторички, NULL-сегмент не деактивируется никогда), +а `scraped_at` — одно и то же. Прод 2026-08: 21 132 из 37 497 «активных» строк +протухли по 14-дневной мерке самого эстиматора и при этом были полностью годны +для якоря. + +Пятый (побочный) дефект, вскрытый тем же замером: `anchor_tier` оставался равным +"C", когда якорь НЕ был построен, и этот залипший флаг молча глушил IMV-blend, +quarter-index и radius-floor — см. вторую половину файла. + +Эти тесты — защита от снятия предиката: именно так дыра и появилась (в главном +пути фильтр есть, в якоре и знаменателе забыли). Проверка на красноту сделана +`git stash` на файлах реализации — все 7 тестов падают без фикса. +""" + +from __future__ import annotations + +import inspect +import os +import re +from datetime import UTC, datetime, timedelta +from typing import Any +from unittest.mock import MagicMock + +# Settings требует DATABASE_URL при инициализации (fail-fast, C-3). +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.core import config as core_config +from app.services import estimator as est_mod +from app.services.estimator import _fetch_analogs, _fetch_anchor_comps, _price_from_inputs +from app.services.geocoder import GeocodeResult +from app.tasks import asking_to_sold_ratio as ratio_mod + +# getattr, а не прямой импорт: на origin/main константы в config ещё нет — тест +# обязан падать ассертом О ЗНАЧЕНИИ (None != 14 / предикат отсутствует), а не +# ImportError'ом на сборке модуля (красное должно значить «значение неверно»). +LISTINGS_FRESH_DAYS = getattr(core_config, "LISTINGS_FRESH_DAYS", None) + +# Ровно тот предикат, что стоит в _COMMON_WHERE радиусного пути. Сверяем ПОДСТРОКОЙ, +# а не регуляркой: цель — чтобы во всех местах стоял ОДИН И ТОТ ЖЕ текст, а не пять +# похожих вариантов, которые потом разъедутся по смыслу. +_PREDICATE = "AND scraped_at > NOW() - (:fresh_days || ' days')::interval" + +_ANCHOR_SRC = inspect.getsource(_fetch_anchor_comps) +_TIER_C_MARK = "# ── Tier C: micro-radius" +_TIER_A_SRC = _ANCHOR_SRC[: _ANCHOR_SRC.index(_TIER_C_MARK)] +_TIER_C_SRC = _ANCHOR_SRC[_ANCHOR_SRC.index(_TIER_C_MARK) :] + +# Tier W не использует _COMMON_WHERE — у него ПОБАЙТОВАЯ inline-копия того же блока +# (комментарий на месте требует держать пути в синхроне). Самое вероятное место +# следующего расхождения — ровно того сорта, что чинит этот PR, поэтому тоже под тестом. +_ANALOGS_SRC = inspect.getsource(_fetch_analogs) +_TIER_W_SRC = _ANALOGS_SRC[_ANALOGS_SRC.index("# ── Tier W: wide") :] + +_REDERIVE_SQL = str(ratio_mod._REDERIVE_SQL.text) +_ASK_SIDE_SQL = _REDERIVE_SQL[ + _REDERIVE_SQL.index("ask_side AS") : _REDERIVE_SQL.index("per_bucket AS") +] +_ASK_GLOBAL_SQL = _REDERIVE_SQL[ + _REDERIVE_SQL.index("ask_global AS") : _REDERIVE_SQL.index("global_row AS") +] + + +# ── 1. Предикат стоит во всех местах, где читаются listings ────────────────── + + +def test_freshness_predicate_present_in_all_four_places() -> None: + """Все денежные выборки несут ТОТ ЖЕ предикат свежести, что _COMMON_WHERE. + + Четыре починенных места + два уже защищённых (_COMMON_WHERE и его inline-копия + в Tier W): их держим под тем же тестом, потому что снятие предиката ТАМ — та же + дыра, только с другой стороны. + + Falsifiable: на непропатченном коде FALSE во всех четырёх починенных (там только + `is_active = true`), TRUE после правки. + """ + places = { + "_COMMON_WHERE (эталон, радиусный путь)": est_mod._COMMON_WHERE, + "_fetch_analogs Tier W (inline-копия _COMMON_WHERE)": _TIER_W_SRC, + "_fetch_anchor_comps Tier A": _TIER_A_SRC, + "_fetch_anchor_comps Tier C": _TIER_C_SRC, + "asking_to_sold_ratio ask_side": _ASK_SIDE_SQL, + "asking_to_sold_ratio ask_global": _ASK_GLOBAL_SQL, + } + missing = [name for name, sql in places.items() if _PREDICATE not in sql] + assert not missing, f"фильтр свежести #2656 снят в: {missing}" + + +def test_freshness_window_lives_in_one_place() -> None: + """Окно берётся из ОДНОЙ константы (app.core.config.LISTINGS_FRESH_DAYS), не из литерала. + + Иначе значения разъедутся — ровно так дыра #2656 и появилась. Проверяем и то, что + оба модуля импортируют имя из config (а не завели свою копию), и то, что в SQL нет + зашитого «14 days». + """ + assert LISTINGS_FRESH_DAYS == 14 # текущее значение; меняется в одном месте + for mod in (est_mod, ratio_mod): + src = inspect.getsource(mod) + assert re.search(r"from app\.core\.config import [^\n]*LISTINGS_FRESH_DAYS", src), ( + f"{mod.__name__}: LISTINGS_FRESH_DAYS не импортирован из app.core.config" + ) + assert not re.search(r"^LISTINGS_FRESH_DAYS\s*=", src, re.M), ( + f"{mod.__name__}: локальная копия константы — значение разъедется" + ) + assert "'14 days'" not in _REDERIVE_SQL + assert "'14 days'" not in _ANCHOR_SRC + + +def test_freshness_param_bound_in_both_anchor_tiers() -> None: + """Оба тира якоря БИНДЯТ :fresh_days — иначе SQL упадёт на невязанном параметре. + + Отдельный тест от предиката: забыть параметр в dict — ровно та ошибка, которую + статическая проверка текста SQL не ловит, а прод ловит 500-й. + """ + for name, src in (("Tier A", _TIER_A_SRC), ("Tier C", _TIER_C_SRC)): + assert '"fresh_days": LISTINGS_FRESH_DAYS,' in src, f"{name}: :fresh_days не забинден" + + +# ── 2. Протухший комп не попадает в пул якоря, свежий попадает ─────────────── + +_ADDRESS = "Екатеринбург, ул. Евгения Савкова, 29" +_LAT, _LON = 56.838, 60.595 + + +def _listing_row( + *, source_id: str, days_ago: int, price_per_m2: float = 150_000.0 +) -> dict[str, Any]: + """Строка listings в форме .mappings() + служебный scraped_at для заглушки ниже.""" + return { + "price_per_m2": price_per_m2, + "area_m2": 50.0, + "rooms": 2, + "floor": 3, + "total_floors": 9, + "address": _ADDRESS, + "source": "avito", + "source_url": f"https://avito.ru/offer/{source_id}", + "price_rub": int(price_per_m2 * 50.0), + "listing_date": None, + "days_on_market": 15, + "photo_urls": [], + "lat": _LAT, + "lon": _LON, + "listing_segment": "vtorichka", + "source_id": source_id, + "scraped_at": datetime.now(tz=UTC) - timedelta(days=days_ago), + } + + +def _predicate_aware_db(rows: list[dict[str, Any]]) -> MagicMock: + """Session-заглушка, ИСПОЛНЯЮЩАЯ предикат свежести вместо Postgres. + + Настоящей БД в тестах нет, поэтому отсев по `scraped_at` делает сама заглушка — + но ТОЛЬКО если SQL реально несёт предикат И :fresh_days забинден. Без фикса + предиката в SQL нет → протухшие строки доезжают до пула якоря → тест краснеет. + Это эмуляция ровно одного WHERE-условия, не мини-Postgres: остальные предикаты + (адрес/гео/сегмент) заданы данными фикстуры так, чтобы они тривиально проходили. + """ + db = MagicMock() + + def _execute(stmt: Any, params: dict[str, Any] | None = None) -> MagicMock: + sql = str(stmt) + kept = rows + if _PREDICATE in sql: + cutoff = datetime.now(tz=UTC) - timedelta(days=params["fresh_days"]) + kept = [r for r in rows if r["scraped_at"] > cutoff] + result = MagicMock() + result.mappings.return_value.all.return_value = kept + return result + + db.execute.side_effect = _execute + return db + + +def test_stale_comps_excluded_fresh_kept_in_anchor_pool() -> None: + """4 свежих + 3 протухших компа → в якорь идут только 4 свежих. + + Прод-мотивация: протухшие строки в сегменте, из которого строится якорь, в среднем + на 7.7% дороже живых (дорогое висит дольше и первым выпадает из выдачи), т.е. + фантом в тонком пуле тянет якорь ВВЕРХ. Замер #2656: 31 дом из 952 получал якорь + Tier A исключительно за счёт протухших. + """ + rows = [_listing_row(source_id=f"f{i}", days_ago=1) for i in range(4)] + rows += [_listing_row(source_id=f"s{i}", days_ago=40, price_per_m2=260_000.0) for i in range(3)] + + comps, tier = _fetch_anchor_comps( + _predicate_aware_db(rows), + address=_ADDRESS, + target_house_id=None, + lat=_LAT, + lon=_LON, + rooms=2, + area=50.0, + ) + + assert tier == "A" + assert len(comps) == 4 + assert all(c["price_per_m2"] == 150_000.0 for c in comps), "протухший комп попал в якорь" + + +def test_thin_pool_loses_anchor_when_only_stale_comps_remain() -> None: + """3 свежих + 5 протухших: было ≥ min_comps=4 → якоря больше нет (Tier D фолбэк). + + Это и есть заявленная цена правки: 139 из 1014 реальных оценок теряют якорь и + уезжают на радиусный путь / ДКП-фолбэк. Честнее, чем якорь на объявлениях, + которых никто не видел месяц. + """ + rows = [_listing_row(source_id=f"f{i}", days_ago=2) for i in range(3)] + rows += [_listing_row(source_id=f"s{i}", days_ago=45) for i in range(5)] + + comps, tier = _fetch_anchor_comps( + _predicate_aware_db(rows), + address=_ADDRESS, + target_house_id=None, + lat=_LAT, + lon=_LON, + rooms=2, + area=50.0, + ) + + # Tier A не набрал порог; Tier C на тех же строках — тоже (заглушка отдаёт их же). + assert tier is None + assert comps == [] + + +# ── 3. anchor_tier сбрасывается, когда якорь не построен ───────────────────── + + +def _geo() -> GeocodeResult: + return GeocodeResult( + lat=_LAT, lon=_LON, full_address="Екатеринбург, ул. Ленина, 5", provider="dadata" + ) + + +def _price( + *, + anchor_comps: list[dict[str, Any]], + anchor_tier_fetched: str | None, + imv_anchor: dict[str, Any] | None = None, + n_listings: int = 6, +) -> est_mod.PricingResult: + listings = [ + {"price_per_m2": 200_000.0, "address": f"ул. Ленина, {i + 5}", "source": "avito"} + for i in range(n_listings) + ] + return _price_from_inputs( + listings=listings, + area_m2=50.0, + rooms=2, + repair_state=None, + floor=5, + total_floors=9, + target_year=None, + analog_tier="W", + fallback_used=False, + area_widened=False, + anchor_comps=anchor_comps, + anchor_tier_fetched=anchor_tier_fetched, + dkp_raw=None, + imv_anchor=imv_anchor, + imv_eval=None, + yandex_val_present=False, + cian_val_present=False, + ratio_resolver=lambda _ppm2: (None, None), + quarter_index_lookup=lambda _q: None, + quarter_indexes_lookup=lambda _qs: {}, + target_house_cadnum=None, + dadata_coarse=False, + geo=_geo(), + dadata_qc_geo=None, + ) + + +def _anchor_comp(ppm2: float) -> dict[str, Any]: + return {"price_per_m2": ppm2, "area_m2": 50.0, "rooms": 2, "floor": 3, "total_floors": 9} + + +def test_anchor_tier_reset_when_anchor_not_built() -> None: + """Якорь не построен (комплов меньше min_comps) → anchor_tier=None, а не залипшая "C". + + Побочный дефект, вскрытый замером #2656: сброс делал только low-conf гейт, а когда + _compute_same_building_anchor возвращала None сама, флаг оставался равным + anchor_tier_fetched. Дальше по коду он читается как «headline построил якорь». + """ + pr = _price( + anchor_comps=[_anchor_comp(150_000.0), _anchor_comp(155_000.0)], anchor_tier_fetched="C" + ) + + assert pr.anchor_tier is None + assert pr.anchor_comps_used == [] + assert pr.median_ppm2 == 200_000.0 # headline остался радиусным + + +def test_sticky_anchor_tier_no_longer_mutes_imv_blend() -> None: + """Тот же случай + IMV-якорь: blend (гейт `anchor_tier is None`) обязан сработать. + + Это и есть денежное последствие залипшего флага: 24 оценки, у которых якоря нет ни + до, ни после правки, всё равно меняли цену — IMV-blend молча глушился. Falsifiable: + без фикса anchor_tier == "C" → blend пропускается → median_price остаётся 10 000 000. + """ + imv = {"recommended_price": 14_000_000} # > медианы (10 млн) × threshold 1.15 + pr = _price( + anchor_comps=[_anchor_comp(150_000.0), _anchor_comp(155_000.0)], + anchor_tier_fetched="C", + imv_anchor=imv, + ) + + assert pr.anchor_tier is None + assert pr.median_price > 10_000_000, "IMV-blend заглушен залипшим anchor_tier" + assert pr.avito_imv_summary is not None + + +def test_imv_card_survives_when_headline_suppressed_and_anchor_absent() -> None: + """Карточка Avito IMV не исчезает в щели «тир добыт, якоря нет, headline подавлен». + + Раньше её заполнял display-only блок под условием `anchor_tier is not None`. После + сброса залипшего флага открылась щель: blend не срабатывает (нужен median_price > 0), + старый блок тоже (tier уже None) — и пользователь ТЕРЯЛ карточку, которую видел + раньше. Отображение, не деньги: median/expected_sold блок не трогает. + + Нулевой headline берём отсутствием аналогов (n=0). Раньше здесь стояло n=3 — + тонкая выборка ниже HEADLINE_LISTINGS_MIN_N, которую гейт достаточности обнулял. + #oblast-F (#2823, смержен 2026-08-09) это поведение снял: тонкая выборка больше + НЕ обнуляет headline, только помечает низкую надёжность. Щель, которую стережёт + тест, от этого не исчезла — она просто достигается единственным оставшимся + нулевым состоянием. + """ + pr = _price( + anchor_comps=[_anchor_comp(150_000.0), _anchor_comp(155_000.0)], + anchor_tier_fetched="C", + imv_anchor={"recommended_price": 14_000_000}, + n_listings=0, + ) + + assert pr.anchor_tier is None + assert pr.median_price == 0 # headline подавлен гейтом достаточности + assert pr.avito_imv_summary is not None, "карточка IMV потеряна" + assert pr.avito_imv_summary.recommended_price == 14_000_000