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
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:
commit
036608f1bc
5 changed files with 419 additions and 7 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
|
|||
350
tradein-mvp/backend/tests/test_freshness_filter_2656.py
Normal file
350
tradein-mvp/backend/tests/test_freshness_filter_2656.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue