Merge pull request 'fix(tradein/estimate): фильтр свежести в якоре дома и знаменателе коэффициента выкупа (#2656)' (#2661) from fix/2656-anchor-ratio-freshness into main
All checks were successful
Deploy Trade-In / test (push) Successful in 3m48s
Deploy Trade-In / build-backend (push) Successful in 1m8s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 1m56s
Deploy Trade-In / deploy-status (push) Successful in 1s

This commit is contained in:
bot-backend 2026-08-26 13:31:53 +00:00
commit 036608f1bc
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). # Роль приложения из data/sql/auth/002_auth_app_role.sql (least privilege).
_AUTH_DB_DEFAULT_USER = "auth_app" _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): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") 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 import text
from sqlalchemy.orm import Session 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.core.db import SessionLocal
from app.schemas.trade_in import ( from app.schemas.trade_in import (
AggregatedEstimate, AggregatedEstimate,
@ -92,7 +92,9 @@ FALLBACK_RADIUS_M = 2000
AREA_TOLERANCE = 0.15 # ±15% площади AREA_TOLERANCE = 0.15 # ±15% площади
MAX_ANALOGS_PER_ADDRESS = 5 # анти-bias: не больше 5 лотов с одного адреса MAX_ANALOGS_PER_ADDRESS = 5 # анти-bias: не больше 5 лотов с одного адреса
MIN_ANALOGS_PER_SOURCE = 5 # гарантированный минимум на live source 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 # сделки за последний год DEALS_PERIOD_MONTHS = 12 # сделки за последний год
# ── ЭТАП 4 B2C launch — anonymous consent-before-save (152-ФЗ) ──────────────── # ── ЭТАП 4 B2C launch — anonymous consent-before-save (152-ФЗ) ────────────────
@ -2229,7 +2231,9 @@ def _fetch_anchor_comps(
ненадёжны.) ненадёжны.)
Tier D фолбэк: None tier (caller остаётся на радиусном median-пути). 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 с Returns (comps, tier) где tier {'A','C', None}. comps list dict с
ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None), ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None),
@ -2255,6 +2259,15 @@ def _fetch_anchor_comps(
listing_segment, source_id listing_segment, source_id
FROM listings FROM listings
WHERE is_active = true 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 price_per_m2 > 0
AND lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like AND lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like
AND lower(translate(address, 'ёЁ', 'ее')) ~ :house_re AND lower(translate(address, 'ёЁ', 'ее')) ~ :house_re
@ -2272,6 +2285,7 @@ def _fetch_anchor_comps(
"lon": lon, "lon": lon,
"lat": lat, "lat": lat,
"radius": ANCHOR_TIER_A_RADIUS_M, "radius": ANCHOR_TIER_A_RADIUS_M,
"fresh_days": LISTINGS_FRESH_DAYS,
}, },
) )
.mappings() .mappings()
@ -2347,6 +2361,9 @@ def _fetch_anchor_comps(
days_on_market, photo_urls, lat, lon days_on_market, photo_urls, lat, lon
FROM listings FROM listings
WHERE is_active = true WHERE is_active = true
-- #2656: окно свежести, симметрично Tier A выше и
-- _COMMON_WHERE радиусного пути (см. комментарий там).
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND price_per_m2 > 0 AND price_per_m2 > 0
AND rooms = CAST(:rooms AS integer) AND rooms = CAST(:rooms AS integer)
AND area_m2 BETWEEN :area_min AND :area_max AND area_m2 BETWEEN :area_min AND :area_max
@ -2366,6 +2383,7 @@ def _fetch_anchor_comps(
"area_max": area * 1.25, "area_max": area * 1.25,
"lon": lon, "lon": lon,
"lat": lat, "lat": lat,
"fresh_days": LISTINGS_FRESH_DAYS,
}, },
) )
.mappings() .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 идёт первым, n_listings>=30 для per_rooms, global -1 строка всегда). ON CONFLICT убран DELETE идёт первым,
конфликтов нет (повторный прогон в одной tx невозможен, refresh = re-seed по семантике). конфликтов нет (повторный прогон в одной 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 (площадь, та же формула, LEAST(GREATEST(rooms,0),4), а ask_side по _AREA_ROOMS_BUCKET_SQL (площадь, та же формула,
что deals.rooms получает при импорте). Причина deals.rooms НЕ настоящая комнатность что deals.rooms получает при импорте). Причина deals.rooms НЕ настоящая комнатность
(Росреестр её не отдаёт), это синтетика из площади; сравнивать её с РЕАЛЬНЫМИ комнатами (Росреестр её не отдаёт), это синтетика из площади; сравнивать её с РЕАЛЬНЫМИ комнатами
@ -43,7 +50,7 @@ import logging
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session 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 from app.services import scrape_runs as runs_mod
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется. # Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
@ -119,7 +126,8 @@ _DELETE_SQL = text(
# бакет LEAST(GREATEST(rooms,0),4) (rooms уже синтетика-из-площади при импорте, см. #2620 # бакет LEAST(GREATEST(rooms,0),4) (rooms уже синтетика-из-площади при импорте, см. #2620
# комментарий у _AREA_ROOMS_BUCKET_SQL выше). # комментарий у _AREA_ROOMS_BUCKET_SQL выше).
# ask_median = percentile_cont(0.5) по listings.price_per_m2 # 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). Бакет — # SOLD-сторона — city IS NULL OR city ILIKE :asking_city, #2583 H2). Бакет —
# _AREA_ROOMS_BUCKET_SQL (площадь, #2620), НЕ listings.rooms — см. комментарий там. # _AREA_ROOMS_BUCKET_SQL (площадь, #2620), НЕ listings.rooms — см. комментарий там.
# per_rooms строки — только при n_deals>=30 AND n_listings>=30 AND ask>0 AND sold>0. # 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 COUNT(*) AS n_listings
FROM listings FROM listings
WHERE is_active 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 AND rooms IS NOT NULL
-- #2620 hardening: area_m2 IS NULL falls into the CASE ELSE branch (bucket 4) -- #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 -- of _AREA_ROOMS_BUCKET_SQL a latent "everything unmeasured looks like a big
@ -216,6 +231,9 @@ _REDERIVE_SQL = text(
COUNT(*) AS n_listings COUNT(*) AS n_listings
FROM listings FROM listings
WHERE is_active WHERE is_active
-- #2656: то же окно свежести, что и в ask_side выше (см. комментарий там)
-- global-строка должна считаться по той же популяции, что per-bucket.
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND rooms IS NOT NULL AND rooms IS NOT NULL
-- #2620 hardening: same area_m2 IS NOT NULL as ask_side — keeps the global-row -- #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. -- 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_min": _PPM2_MIN,
"ppm2_max": settings.asking_ratio_ppm2_max, "ppm2_max": settings.asking_ratio_ppm2_max,
"asking_city": _ASKING_CITY_PATTERN, "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 #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 (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. 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") seed_sql = _MIGRATION_080.read_text("utf-8")
# Extract the WITH … (up to the ON CONFLICT) from the seed. # 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) 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: def _norm(s: str) -> str:
return _strip_sql( return _strip_sql(
_normalise_ppm2( _normalise_ppm2(
_normalise_bucket_expr( _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