fix(tradein/estimate): фильтр свежести в якоре дома и знаменателе коэффициента выкупа (#2656) #2661

Merged
bot-backend merged 7 commits from fix/2656-anchor-ratio-freshness into main 2026-08-26 13:31:53 +00:00
5 changed files with 419 additions and 7 deletions

View file

@ -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")

View file

@ -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()

View file

@ -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,
},
)

View file

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

View file

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