fix(tradein/estimate): домовой IMV-якорь приводится к базису ремонта квартиры (#2677) #2816

Merged
bot-backend merged 1 commit from fix/2677-house-anchor-renovation into main 2026-08-12 20:17:11 +00:00
2 changed files with 277 additions and 4 deletions

View file

@ -419,6 +419,32 @@ def _repair_coefficient(repair_state: str | None) -> float:
return _REPAIR_COEF.get(repair_state, 1.0)
# Обратный `_IMV_REPAIR_MAP`: вокабуляр Avito → наш repair_state.
_IMV_REPAIR_MAP_REV: dict[str, str] = {v: k for k, v in _IMV_REPAIR_MAP.items() if k and v}
def _anchor_repair_factor(anchor_renovation: str | None, target_repair: str | None) -> float:
"""#2677: множитель, приводящий домовой IMV-якорь к базису ремонта target'а.
Домовая строка `house_imv_evaluations` запрошена у Avito с ОДНИМ ремонтом
(`renovation_type`, до #2675 — литерал 'cosmetic' у всех 2685 строк), а медиана
к моменту blend'а уже домножена на `_repair_coefficient(target_repair)`. Сравнивать
и смешивать их напрямую значит класть два разных базиса на одну шкалу: у клиента
с «требует ремонта» медиана 6%, якорь нет, порог `median×1.15` фактически падает до
×1.081, а сработавший blend возвращает половину этих 6% обратно вверх.
Это НЕ заявка на правильность самих коэффициентов (`_REPAIR_COEF` рыночная
эвристика, см. предупреждение выше): множитель берётся ровно тот, что код УЖЕ
применил к медиане, чинится расхождение базисов, а не калибровка.
Неизвестный/незнакомый ремонт с любой стороны 1.0 (no-op, прежнее поведение).
"""
base = _repair_coefficient(_IMV_REPAIR_MAP_REV.get(anchor_renovation or ""))
if base <= 0:
return 1.0
return _repair_coefficient(target_repair) / base
# ── Asking→sold correction ratio lookup (#648 Stage 3) ──────────────────────
# Таблица asking_to_sold_ratios (migration 080) хранит per-rooms коэффициент
# ratio = median(SOLD ppm²) / median(ASKING ppm²) (~0.720.93). Estimator
@ -1096,7 +1122,11 @@ def _fetch_house_imv_anchor(
house_id / пустой таблице estimator продолжает на гео-tier'ах (no regress).
Returns dict {recommended_price, lower_price, higher_price, market_count,
rooms, area_m2} или None.
rooms, area_m2, renovation_type} или None.
#2677: renovation_type — ремонт, С КОТОРЫМ строка была запрошена у Avito.
Без него якорь нельзя привести к базису ремонта оцениваемой квартиры
(см. `_anchor_repair_factor`), и правка молча выродилась бы в no-op.
"""
if target_house_id is None:
return None
@ -1106,7 +1136,7 @@ def _fetch_house_imv_anchor(
text(
"""
SELECT recommended_price, lower_price, higher_price,
market_count, rooms, area_m2
market_count, rooms, area_m2, renovation_type
FROM house_imv_evaluations
WHERE house_id = CAST(:hid AS bigint)
AND recommended_price > 0
@ -2901,6 +2931,7 @@ def _price_from_inputs(
anchor_total: int | None = None
anchor_higher: int | None = None
anchor_label: str | None = None
anchor_repair_note: str = ""
if imv_anchor is not None and imv_anchor.get("recommended_price"):
anchor_total = int(imv_anchor["recommended_price"])
anchor_higher = (
@ -2919,6 +2950,22 @@ def _price_from_inputs(
_imv_mc is not None and _imv_mc < settings.avito_imv_thin_market_threshold
),
)
# #2677: карточка выше показывает СЫРУЮ оценку Avito (это чужое число, мы его
# не правим), а в blend уходит якорь, приведённый к базису ремонта target'а
# тому же, в котором median_price уже посчитан выше (repair_coef).
_rep_k = _anchor_repair_factor(imv_anchor.get("renovation_type"), repair_state)
if _rep_k != 1.0:
anchor_total = round(anchor_total * _rep_k)
anchor_higher = round(anchor_higher * _rep_k) if anchor_higher else None
anchor_repair_note = ", приведённой к состоянию ремонта квартиры"
logger.info(
"imv_anchor repair-basis #2677: renovation=%s target_repair=%s k=%.3f"
" → anchor %d",
imv_anchor.get("renovation_type"),
repair_state,
_rep_k,
anchor_total,
)
elif imv_eval is not None and imv_eval.recommended_price:
anchor_total = int(imv_eval.recommended_price)
anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None
@ -2968,8 +3015,8 @@ def _price_from_inputs(
median_price = new_median
median_ppm2 = new_ppm2
explanation = (explanation or "") + (
f" Оценка скорректирована по {anchor_label} "
f"({anchor_used / 1_000_000:.1f} млн ₽)."
f" Оценка скорректирована по {anchor_label}"
f"{anchor_repair_note} ({anchor_used / 1_000_000:.1f} млн ₽)."
)
sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"})
# Диапазон расширяем даже если медиану не двигали.

View file

@ -0,0 +1,226 @@
"""#2677: домовой IMV-якорь приводится к базису ремонта оцениваемой квартиры.
Дефект: `median_price` к моменту blend'а уже домножен на `_repair_coefficient`
(«требует ремонта» 6%, «евро» +10%), а домовой якорь из `house_imv_evaluations`
запрошен у Avito с ОДНИМ ремонтом (`renovation_type`). Порог `anchor > median×1.15`
и сам blend клали два разных базиса на одну шкалу и, поскольку blend
однонаправленный (только вверх), у клиента с «требует ремонта» это возвращало
половину его 6% обратно наверх.
Замер на проде (1061 персистированная оценка, 2026-08-10): blend не сработал ни
разу (0 маркеров в confidence_explanation), но из 240 оценок с домовым якорем порог
пересекали 19 9 из них «требует ремонта», и на них старый код давал медиану
на 3.39.8 % выше базис-согласованной (суммарно +4.13 млн ).
"""
import os
from typing import Any
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
from app.services.estimator import _fetch_house_imv_anchor
# `_anchor_repair_factor` импортируется ВНУТРИ тестов намеренно: на коде без правки
# модуль обязан импортироваться, чтобы сквозные тесты ниже дошли до выполнения и
# упали на ЦИФРАХ (красный по существу), а не на collection error.
# Аналоги дают ровно 150 000 ₽/м² медианой → при area 40 м² pre-repair медиана 6 000 000 ₽.
_PPM2 = 150_000.0
_AREA = 40.0
_BASE_MEDIAN = int(_PPM2 * _AREA)
def _make_listing(price_per_m2: float) -> dict[str, Any]:
return {
"id": int(price_per_m2),
"source": "avito",
"address": "ЕКБ, ул. Учителей, 18",
"price_rub": int(price_per_m2 * _AREA),
"price_per_m2": price_per_m2,
"area_m2": _AREA,
"rooms": 1,
"floor": 4,
"total_floors": 16,
"lat": 56.838,
"lon": 60.595,
"listing_date": None,
"days_on_market": None,
"source_url": f"https://example.test/{int(price_per_m2)}",
"photo_urls": [],
"distance_m": 10.0,
}
_ANALOGS = [
_make_listing(135_000.0),
_make_listing(140_000.0),
_make_listing(145_000.0),
_make_listing(_PPM2),
_make_listing(155_000.0),
_make_listing(160_000.0),
_make_listing(165_000.0),
]
def _run_estimate(anchor: dict[str, Any], repair_state: str | None) -> Any:
"""estimate_quality со всеми I/O застабленными; домовой якорь форсирован."""
from app.schemas.trade_in import TradeInEstimateInput
from app.services.estimator import estimate_quality
from app.services.geocoder import GeocodeResult
payload = TradeInEstimateInput(
address="ЕКБ, ул. Учителей, 18",
area_m2=_AREA,
rooms=1,
floor=4,
total_floors=16,
repair_state=repair_state,
)
geo = GeocodeResult(
lat=56.838,
lon=60.595,
full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
provider="nominatim",
)
async def _run() -> Any:
with (
patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False),
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch(
"app.services.estimator._fetch_analogs",
return_value=(list(_ANALOGS), False, "S"),
),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch("app.services.estimator._fetch_dkp_corridor", return_value=None),
patch(
"app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)
),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None),
),
patch("app.services.estimator._get_asking_sold_ratio", return_value=(0.8, "per_rooms")),
patch("app.services.estimator._fetch_house_imv_anchor", return_value=anchor),
):
return await estimate_quality(payload, MagicMock())
return anyio.run(_run)
def _anchor(recommended: int, renovation: str | None) -> dict[str, Any]:
return {
"recommended_price": recommended,
"lower_price": int(recommended * 0.97),
"higher_price": int(recommended * 1.05),
"market_count": 500,
"rooms": 1,
"area_m2": _AREA,
"renovation_type": renovation,
}
# ── чистая функция ───────────────────────────────────────────────────────────
def test_repair_factor_is_ratio_of_the_same_coefficients() -> None:
"""Множитель = coef(ремонт клиента) / coef(ремонт строки якоря), а не что-то новое."""
from app.services.estimator import _anchor_repair_factor
# строка 'cosmetic' (=standard, 1.00) → клиент «требует ремонта» (0.94)
assert _anchor_repair_factor("cosmetic", "needs_repair") == 0.94
# строка 'euro' (=good, 1.05) → клиент со стандартным ремонтом (1.00)
assert _anchor_repair_factor("euro", "standard") == 1.0 / 1.05
# строка 'required' (=needs_repair, 0.94) → клиент с евро (1.10)
assert _anchor_repair_factor("required", "excellent") == 1.10 / 0.94
def test_repair_factor_unknown_anchor_renovation_falls_back_to_standard_basis() -> None:
"""Незнакомый/пустой `renovation_type` считается стандартным базисом, а НЕ «не трогать».
Так строка и рождается: `house_imv_backfill._map_renovation_type` при неизвестном
ремонте дома шлёт Avito 'cosmetic' (=standard) как медиану популяции. Фолбэк в
1.0 при дрейфе вокабуляра тихо вернул бы дефект поэтому базис, а не no-op.
"""
from app.services.estimator import _anchor_repair_factor
assert _anchor_repair_factor("что-то-новое", "needs_repair") == 0.94
assert _anchor_repair_factor(None, "needs_repair") == 0.94
# Неизвестен ремонт КЛИЕНТА — правки нет: медиана тоже осталась без коэффициента.
assert _anchor_repair_factor(None, None) == 1.0
assert _anchor_repair_factor("cosmetic", None) == 1.0
assert _anchor_repair_factor(None, "standard") == 1.0
# ── SQL-граница: без колонки правка молча выродится в no-op ──────────────────
def test_anchor_query_selects_renovation_type() -> None:
"""`renovation_type` обязан быть в SELECT: без него `.get()` вернёт None → k=1.0.
Проверка строковая намеренно это единственная точка, где видно, доедет ли
колонка из БД до `_anchor_repair_factor`; поштучный unit-тест по ту сторону
границы получает dict уже от вызывающего и такую регрессию не увидит.
"""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = None
_fetch_house_imv_anchor(db, target_house_id=11308, rooms=1, area=_AREA)
sql = str(db.execute.call_args[0][0])
assert "renovation_type" in sql.split("FROM house_imv_evaluations")[0]
# ── сквозь estimate_quality: цена, которую видит пользователь ────────────────
def test_needs_repair_client_not_lifted_by_cosmetic_anchor() -> None:
"""«Требует ремонта»: якорь между старым и базис-согласованным порогом → blend НЕ идёт.
median = 6 000 000 × 0.94 = 5 640 000. Старый порог: 5 640 000 × 1.15 = 6 486 000
якорь 6 700 000 его перекрывает и старый код поднимает медиану до 6 170 000.
Базис-согласованно: якорь 'cosmetic' в базисе «требует ремонта» = 6 298 000
< 6 486 000 медиана остаётся 5 640 000 (8.6 % к старому поведению).
"""
est = _run_estimate(_anchor(6_700_000, "cosmetic"), "needs_repair")
assert est.median_price_rub == int(_BASE_MEDIAN * 0.94)
assert "скорректирована по оценке Avito IMV" not in (est.confidence_explanation or "")
def test_euro_anchor_not_applied_raw_to_unknown_repair_client() -> None:
"""Сценарий из #2677: дом переснят как «евро», ремонт клиента неизвестен.
median = 6 000 000 (coef 1.0). Старый порог 6 900 000 якорь 7 000 000 его
перекрывает и поднимает медиану до 6 500 000. В базисе клиента якорь 'euro'
стоит 7 000 000 / 1.05 = 6 666 667 < 6 900 000 медиана не двигается.
"""
est = _run_estimate(_anchor(7_000_000, "euro"), None)
assert est.median_price_rub == _BASE_MEDIAN
def test_blend_still_fires_and_reports_the_rebased_anchor() -> None:
"""Правка не глушит механизм: якорь, крупный и после пересчёта, по-прежнему блендится.
median = 5 640 000, якорь 'cosmetic' 10 000 000 в базисе клиента 9 400 000
> 6 486 000 blend = (5 640 000 + 9 400 000) / 2 = 7 520 000. В пояснении
ТО ЖЕ число, что ушло в расчёт, и пометка про пересчёт (иначе текст спорил бы
с карточкой, где показана сырая оценка Avito).
"""
est = _run_estimate(_anchor(10_000_000, "cosmetic"), "needs_repair")
assert est.median_price_rub == (int(_BASE_MEDIAN * 0.94) + 9_400_000) // 2
explanation = est.confidence_explanation or ""
assert "9.4 млн ₽" in explanation
assert "приведённой к состоянию ремонта квартиры" in explanation
# Карточка Avito остаётся сырой — чужое число мы не правим.
assert est.avito_imv is not None
assert est.avito_imv.recommended_price == 10_000_000