feat(tradein): blend Avito IMV anchor into estimate + ДКП corridor advisory (#651, #652) (#674)
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-backend (push) Successful in 46s
Deploy Trade-In / build-frontend (push) Successful in 1m38s
Deploy Trade-In / deploy (push) Successful in 36s

Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
bot-backend 2026-05-29 17:42:06 +00:00 committed by bot-reviewer
parent 29679a9545
commit 96f02d37f3
6 changed files with 830 additions and 14 deletions

View file

@ -54,6 +54,19 @@ class Settings(BaseSettings):
dadata_api_token: str | None = None
dadata_api_secret: str | None = None
# ── #651: IMV / Yandex blend (killer accuracy fix) ──────────────────────
# Радиусная медиана ₽/м² системно недооценивает премиум/видовые квартиры
# (нет class/segment/IMV-коррекции → premium ~2x underestimate, case 50М vs
# факт ~100М). Если внешний якорь (Avito IMV recommended_price из
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
# Полностью за флагами — безопасно выкатить до демо; при отсутствии IMV/Yandex
# no-op (медиана не меняется).
estimate_imv_blend_enabled: bool = True
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
# ── Estimate enrichment time-budgets (#654) ──────────────────────────────
# POST /estimate делает несколько ПОСЛЕДОВАТЕЛЬНЫХ блокирующих сетевых
# вызовов (geocode → Overpass → Yandex valuation → IMV → Cian). Yandex

View file

@ -73,6 +73,38 @@ class CianValuationSummary(BaseModel):
chart_change_direction: Literal["increase", "decrease", "neutral"] | None = None
class AvitoImvSummary(BaseModel):
"""Avito IMV (Индекс Market Value) якорь для target-дома (#651).
Источник: `house_imv_evaluations` (per house_id, обновляется регулярно).
Это РЕАЛЬНАЯ рыночная оценка Avito по дому служит anchor'ом для blend'а
(см. estimate_imv_blend_*). Сурфейсится в UI как референсный маркер на
ценовой шкале. None если для дома нет свежей IMV-записи.
"""
recommended_price: int | None = None # рекомендованная цена Avito, ₽
lower_price: int | None = None # нижняя граница IMV-коридора, ₽
higher_price: int | None = None # верхняя граница IMV-коридора, ₽
market_count: int | None = None # объём рынка, на котором построена оценка
class DkpCorridor(BaseModel):
"""Коридор реальных ДКП-сделок Росреестра для target (#652).
Источник: `deals` (source='rosreestr', ДКП-only), агрегированные по улице +
rooms + площади ±15% за период. ADVISORY: показывается как тонкая референсная
линия «коридор реальных сделок: XY млн»; если итоговая медиана /м² выходит
за [low,high]×slack добавляется текстовая пометка. НЕ хард-клампит оценку.
None / count=0 если по улице нет сопоставимых сделок.
"""
count: int # число ДКП-сделок в выборке
low_ppm2: int # min ₽/м² по сделкам (P10-ish — берём минимум)
median_ppm2: int # медиана ₽/м²
high_ppm2: int # max ₽/м²
period_months: int # окно поиска сделок
class AggregatedEstimate(BaseModel):
estimate_id: UUID
median_price_rub: int
@ -94,6 +126,13 @@ class AggregatedEstimate(BaseModel):
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам)
cian_valuation: CianValuationSummary | None = None
# ── #651/#652: внешние якоря (Avito IMV) + коридор реальных сделок (ДКП) ──
# avito_imv — реальная IMV-оценка дома (house_imv_evaluations). Используется
# как anchor для blend'а медианы (см. confidence_explanation).
# dkp_corridor — коридор ₽/м² по ДКП-сделкам Росреестра (advisory, не клампит).
# Оба nullable — None при отсутствии данных (graceful, common case без регрессий).
avito_imv: AvitoImvSummary | None = None
dkp_corridor: DkpCorridor | None = None
# ── Asking→sold correction (#648 Stage 3) — PURELY ADDITIVE ──
# Headline median_price_rub/range_*/median_price_per_m2 остаются ASKING (активные
# объявления). Эти параллельные expected_sold_* = asking × per-rooms ratio

View file

@ -37,7 +37,9 @@ from app.core.config import settings
from app.schemas.trade_in import (
AggregatedEstimate,
AnalogLot,
AvitoImvSummary,
CianValuationSummary,
DkpCorridor,
TradeInEstimateInput,
)
from app.services.dadata import DadataAddressResult
@ -660,6 +662,188 @@ def _save_yandex_history_items(
return 0
# ── #651: IMV / Yandex blend (killer accuracy fix) ─────────────────────────────
def _fetch_house_imv_anchor(
db: Session,
*,
target_house_id: int | None,
rooms: int | None,
area: float | None,
) -> dict[str, Any] | None:
"""Достаёт РЕАЛЬНУЮ Avito IMV-оценку target-дома из `house_imv_evaluations`.
В отличие от `avito_imv_evaluations` (keyed estimate_id пустая, on-demand
скрейп), `house_imv_evaluations` популирована (~2951 домов, fresh) и keyed по
house_id. Резолвим строку: WHERE house_id = target_house_id, предпочитаем
запись с ближайшими rooms+area (минимизируем |Δrooms|*10 + |Δarea%|), иначе
самую свежую (fetched_at DESC). Best-effort: None при любой ошибке / отсутствии
house_id / пустой таблице estimator продолжает на гео-tier'ах (no regress).
Returns dict {recommended_price, lower_price, higher_price, market_count,
rooms, area_m2} или None.
"""
if target_house_id is None:
return None
try:
row = (
db.execute(
text(
"""
SELECT recommended_price, lower_price, higher_price,
market_count, rooms, area_m2
FROM house_imv_evaluations
WHERE house_id = CAST(:hid AS bigint)
AND recommended_price > 0
-- Band-guard: строка пригодна как anchor только при правдоподобном
-- совпадении rooms/area. Иначе studio-only IMV запись «прилипала»
-- к 3-комн. target'у (ORDER BY всё равно вернёт LIMIT 1) и при
-- anchor > median×1.15 раздувала blend. NULL target/row не
-- режем (graceful, нет данных для сравнения).
AND (
CAST(:rooms AS integer) IS NULL OR rooms IS NULL
OR abs(rooms - CAST(:rooms AS integer)) <= 1
)
AND (
CAST(:area AS double precision) IS NULL
OR area_m2 IS NULL OR area_m2 <= 0
OR area_m2 BETWEEN CAST(:area AS double precision) * 0.7
AND CAST(:area AS double precision) * 1.3
)
ORDER BY
-- ближе по комнатам и площади меньше score; NULL target 0
(CASE WHEN CAST(:rooms AS integer) IS NOT NULL AND rooms IS NOT NULL
THEN abs(rooms - CAST(:rooms AS integer)) * 10 ELSE 0 END)
+ (CASE WHEN CAST(:area AS double precision) IS NOT NULL
AND area_m2 IS NOT NULL AND area_m2 > 0
THEN abs(area_m2 - CAST(:area AS double precision))
/ area_m2 * 100 ELSE 0 END) ASC,
fetched_at DESC
LIMIT 1
"""
),
{"hid": target_house_id, "rooms": rooms, "area": area},
)
.mappings()
.first()
)
except Exception as exc: # pragma: no cover — defensive
logger.warning("house_imv anchor lookup failed (graceful): %s", exc)
return None
return dict(row) if row is not None else None
def _apply_imv_blend(
*,
median_price: int,
range_high: int,
median_ppm2: float,
area: float,
anchor_total: int | None,
anchor_higher: int | None,
weight: float,
threshold: float,
) -> tuple[int, int, float, bool, int | None]:
"""Чистая (testable без БД) blend-трансформация для #651.
Если есть надёжный якорь A (`anchor_total`, ПОЛНАЯ цена за квартиру) и он
выше median_price × threshold (сигнал занижения) поднимаем медиану до
blend = median*(1-w) + A*w и расширяем верх диапазона до max(range_high,
anchor_higher или A). ОДНОНАПРАВЛЕННО: только повышаем (баг занижение).
Если A ниже медианы медиану НЕ трогаем, но диапазон можем расширить, чтобы
включить A (информативность). Null-guard: при anchor_total=None no-op.
Returns (new_median_price, new_range_high, new_median_ppm2, blended,
anchor_used_total).
"""
if anchor_total is None or anchor_total <= 0 or median_price <= 0 or area <= 0:
return median_price, range_high, median_ppm2, False, None
blended = False
new_median = median_price
new_ppm2 = median_ppm2
if anchor_total > median_price * threshold:
w = max(0.0, min(1.0, weight))
new_median = round(median_price * (1.0 - w) + anchor_total * w)
new_ppm2 = new_median / area
blended = True
# Расширяем верх диапазона: предпочитаем верхнюю границу IMV-коридора, иначе сам
# якорь. Только вверх (никогда не сужаем).
range_top_candidate = anchor_higher if (anchor_higher and anchor_higher > 0) else anchor_total
new_range_high = max(range_high, range_top_candidate, new_median)
return new_median, new_range_high, new_ppm2, blended, anchor_total
def _fetch_dkp_corridor(
db: Session,
*,
address: str | None,
rooms: int | None,
area: float | None,
period_months: int = DEALS_PERIOD_MONTHS,
area_tolerance: float = AREA_TOLERANCE,
) -> dict[str, Any] | None:
"""#652: коридор ₽/м² по реальным ДКП-сделкам Росреестра для target.
Reuse паттерна street-deals (api/v1/trade_in.py): извлекаем улицу из адреса,
фильтруем `deals` (source='rosreestr', та же rooms, площадь ±tolerance, окно
period_months) и нормализуем per-. Возвращаем low/median/high /м².
ADVISORY caller не клампит, только сурфейсит + опциональная пометка.
Best-effort: None при отсутствии улицы / сделок / любой ошибке.
"""
if not address or rooms is None or not area:
return None
street_name = extract_street_name(address)
if not street_name:
return None
area_min = area * (1.0 - area_tolerance)
area_max = area * (1.0 + area_tolerance)
try:
rows = (
db.execute(
text(
"""
SELECT price_per_m2
FROM deals
WHERE source = 'rosreestr'
AND address ILIKE :street_pattern
AND rooms = CAST(:rooms AS integer)
AND area_m2 BETWEEN :area_min AND :area_max
AND deal_date > NOW()
- (CAST(:period_months AS integer) || ' months')::interval
AND price_per_m2 > 0
"""
),
{
"street_pattern": "%" + street_name + "%",
"rooms": rooms,
"area_min": area_min,
"area_max": area_max,
"period_months": period_months,
},
)
.mappings()
.all()
)
except Exception as exc: # pragma: no cover — defensive
logger.warning("dkp_corridor lookup failed (graceful): %s", exc)
return None
ppm2_values = sorted(float(r["price_per_m2"]) for r in rows if r["price_per_m2"])
if not ppm2_values:
return None
return {
"count": len(ppm2_values),
"low_ppm2": int(ppm2_values[0]),
"median_ppm2": int(_percentile(ppm2_values, 0.5)),
"high_ppm2": int(ppm2_values[-1]),
"period_months": period_months,
}
# ── Time-budget guard (#654) ────────────────────────────────────────────────
async def _with_budget(coro: Any, budget_s: float, *, label: str) -> Any:
"""Await `coro` under an asyncio.wait_for() time budget.
@ -680,7 +864,6 @@ async def _with_budget(coro: Any, budget_s: float, *, label: str) -> Any:
logger.warning("%s exceeded %.1fs budget — degrading to None (#654)", label, budget_s)
return None
# ── Public ───────────────────────────────────────────────────────────────────
async def estimate_quality(
payload: TradeInEstimateInput, db: Session, created_by: str | None = None
@ -894,20 +1077,19 @@ async def estimate_quality(
# сделки (backtest #648 S1: bias asking-медианы +20% → 4% на held-out ДКП).
# NOTE: actual_deals (#564) остаётся ИНФОРМАЦИОННЫМ и НЕ подмешивается в
# headline — sold-коррекция здесь единственный sold-сигнал (без double-count).
# NOTE: expected_sold_* (= asking × ratio) выводятся НЕ здесь, а ПОСЛЕ #651
# IMV-blend (ниже), который мутирует median_price/median_ppm2/range_high. Иначе
# expected_sold остаётся pre-blend → asking 75M / sold 45M (бессмысленная скидка
# в HeroSummary) и stale-значения persist'ятся в trade_in_estimates. Здесь только
# резолвим ratio/basis (нужны для confidence/explanation и null-guard).
asking_to_sold_ratio, ratio_basis = _get_asking_sold_ratio(db, payload.rooms)
if asking_to_sold_ratio is not None and listings_clean:
expected_sold_per_m2: int | None = round(median_ppm2 * asking_to_sold_ratio)
expected_sold_price: int | None = round(median_price * asking_to_sold_ratio)
expected_sold_range_low: int | None = round(range_low * asking_to_sold_ratio)
expected_sold_range_high: int | None = round(range_high * asking_to_sold_ratio)
else:
expected_sold_per_m2 = None
expected_sold_price = None
expected_sold_range_low = None
expected_sold_range_high = None
# Не было ratio (нет таблицы/бакета) — не вводим в заблуждение пустым basis.
if asking_to_sold_ratio is None:
ratio_basis = None
expected_sold_per_m2: int | None = None
expected_sold_price: int | None = None
expected_sold_range_low: int | None = None
expected_sold_range_high: int | None = None
# Не было ratio (нет таблицы/бакета) — не вводим в заблуждение пустым basis.
if asking_to_sold_ratio is None:
ratio_basis = None
confidence, explanation = _compute_confidence(
n_analogs,
@ -1024,6 +1206,114 @@ async def estimate_quality(
except Exception as exc:
logger.warning("cian_valuation: lookup failed (graceful): %s", exc)
# ── #651: IMV / Yandex BLEND (killer accuracy fix) ──────────────────────
# Радиусная медиана системно занижает премиум/видовые квартиры (нет class/
# segment-коррекции). Берём РЕАЛЬНЫЙ Avito IMV target-дома из house_imv_evaluations
# (avito_imv_evaluations пуст — keyed estimate_id, on-demand), используем как
# anchor: если IMV recommended_price > median × threshold — поднимаем медиану
# blend'ом и расширяем верх диапазона. Всё за флагом + null-guard (no-op без IMV).
avito_imv_summary: AvitoImvSummary | None = None
if settings.estimate_imv_blend_enabled and listings_clean and median_price > 0:
imv_anchor = _fetch_house_imv_anchor(
db,
target_house_id=target_house_id,
rooms=payload.rooms,
area=payload.area_m2,
)
# Anchor chain: prefer Avito IMV recommended; fall back to on-demand imv_eval.
anchor_total: int | None = None
anchor_higher: int | None = None
anchor_label: str | None = None
if imv_anchor is not None and imv_anchor.get("recommended_price"):
anchor_total = int(imv_anchor["recommended_price"])
anchor_higher = (
int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None
)
anchor_label = "оценке Avito IMV"
avito_imv_summary = AvitoImvSummary(
recommended_price=anchor_total,
lower_price=(
int(imv_anchor["lower_price"]) if imv_anchor.get("lower_price") else None
),
higher_price=anchor_higher,
market_count=(
int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None
),
)
elif imv_eval is not None and imv_eval.recommended_price:
# on-demand IMV (avito_imv_evaluations) — fallback, обычно пуст
anchor_total = int(imv_eval.recommended_price)
anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None
anchor_label = "оценке Avito IMV"
avito_imv_summary = AvitoImvSummary(
recommended_price=anchor_total,
lower_price=int(imv_eval.lower_price) if imv_eval.lower_price else None,
higher_price=anchor_higher,
market_count=imv_eval.market_count,
)
if anchor_total is not None:
new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend(
median_price=median_price,
range_high=range_high,
median_ppm2=median_ppm2,
area=payload.area_m2,
anchor_total=anchor_total,
anchor_higher=anchor_higher,
weight=settings.estimate_imv_blend_weight,
threshold=settings.estimate_imv_blend_threshold,
)
if blended:
logger.info(
"imv_blend: median %d%d (anchor=%d w=%.2f) range_high %d%d",
median_price, new_median, anchor_used,
settings.estimate_imv_blend_weight, range_high, new_range_high,
)
median_price = new_median
median_ppm2 = new_ppm2
explanation = (explanation or "") + (
f" Оценка скорректирована по {anchor_label} "
f"({anchor_used / 1_000_000:.1f} млн ₽)."
)
sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"})
# Диапазон расширяем даже если медиану не двигали (информативность).
range_high = new_range_high
# 4c (cont.). expected_sold_* выводим ЗДЕСЬ — ПОСЛЕ #651 IMV-blend, который мог
# поднять median_price/median_ppm2 и расширить range_high. Применяем ratio к
# POST-blend значениям → asking (median_price_rub) и sold (expected_sold_price_rub)
# консистентны в HeroSummary, и в DB persist'ятся свежие значения (no stale 40%
# «скидки»). range_low blend не трогает — берём как есть.
if asking_to_sold_ratio is not None and listings_clean:
expected_sold_per_m2 = round(median_ppm2 * asking_to_sold_ratio)
expected_sold_price = round(median_price * asking_to_sold_ratio)
expected_sold_range_low = round(range_low * asking_to_sold_ratio)
expected_sold_range_high = round(range_high * asking_to_sold_ratio)
# ── #652: ДКП-коридор реальных сделок (ADVISORY + soft sanity-bound) ─────
dkp_corridor: DkpCorridor | None = None
dkp_raw = _fetch_dkp_corridor(
db,
address=geo.full_address,
rooms=payload.rooms,
area=payload.area_m2,
)
if dkp_raw is not None:
dkp_corridor = DkpCorridor(**dkp_raw)
# Soft sanity-bound: если итоговая медиана ₽/м² заметно вне коридора —
# текстовая пометка (без хард-клампа). slack ±25% — широко, чтобы не
# шуметь на тонких выборках.
slack = 0.25
if median_ppm2 and dkp_raw["count"] >= 3:
if median_ppm2 > dkp_raw["high_ppm2"] * (1.0 + slack):
explanation = (explanation or "") + (
" Оценка выше коридора реальных сделок Росреестра по улице."
)
elif median_ppm2 < dkp_raw["low_ppm2"] * (1.0 - slack):
explanation = (explanation or "") + (
" Оценка ниже коридора реальных сделок Росреестра по улице."
)
# 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals.
# Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков
# исключены — больше не скёюят median вторички ~110-120 К/м².
@ -1224,6 +1514,8 @@ async def estimate_quality(
if cian_val is not None
else None
),
avito_imv=avito_imv_summary,
dkp_corridor=dkp_corridor,
expected_sold_price_rub=expected_sold_price,
expected_sold_range_low_rub=expected_sold_range_low,
expected_sold_range_high_rub=expected_sold_range_high,

View file

@ -0,0 +1,410 @@
"""Unit tests for #651 IMV blend (killer accuracy fix) + #652 DKP corridor.
Тестируем чистую blend-трансформацию `_apply_imv_blend` (без БД) и DB-helpers
с замоканной Session premium-blend поднимает медиану + расширяет диапазон,
no-anchor / sub-threshold случаи no-op.
"""
import os
from datetime import UTC, datetime
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 (
_apply_imv_blend,
_fetch_dkp_corridor,
_fetch_house_imv_anchor,
)
# ── #651: pure blend transform ────────────────────────────────────────────────
def test_blend_premium_raises_median_and_extends_range() -> None:
"""IMV ≫ median → median поднимается blend'ом, range_high расширяется."""
# median 50М, IMV recommended 100М (≈2x — premium/view unit underestimate),
# IMV higher 110М. weight 0.5, threshold 1.15.
area = 80.0
median_price = 50_000_000
range_high = 60_000_000
median_ppm2 = median_price / area
new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend(
median_price=median_price,
range_high=range_high,
median_ppm2=median_ppm2,
area=area,
anchor_total=100_000_000,
anchor_higher=110_000_000,
weight=0.5,
threshold=1.15,
)
assert blended is True
# blend = 50М*0.5 + 100М*0.5 = 75М
assert new_median == 75_000_000
# range_high расширен до IMV higher (110М)
assert new_range_high == 110_000_000
# ppm2 пересчитан консистентно
assert new_ppm2 == 75_000_000 / area
assert anchor_used == 100_000_000
def test_blend_no_op_when_anchor_below_median() -> None:
"""A < median → медиану НЕ понижаем (однонаправленность), но диапазон может расшириться."""
area = 50.0
median_price = 10_000_000
range_high = 11_000_000
median_ppm2 = median_price / area
new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend(
median_price=median_price,
range_high=range_high,
median_ppm2=median_ppm2,
area=area,
anchor_total=8_000_000, # ниже медианы
anchor_higher=8_500_000,
weight=0.5,
threshold=1.15,
)
assert blended is False
assert new_median == median_price # не понижаем
assert new_ppm2 == median_ppm2
assert anchor_used == 8_000_000
# range_high не сужается (anchor ниже текущего high)
assert new_range_high == range_high
def test_blend_no_op_below_threshold() -> None:
"""A выше median, но в пределах threshold → blend не срабатывает (медиана та же)."""
area = 40.0
median_price = 10_000_000
range_high = 11_000_000
median_ppm2 = median_price / area
new_median, _, new_ppm2, blended, _ = _apply_imv_blend(
median_price=median_price,
range_high=range_high,
median_ppm2=median_ppm2,
area=area,
anchor_total=11_000_000, # ×1.1 < threshold 1.15
anchor_higher=None,
weight=0.5,
threshold=1.15,
)
assert blended is False
assert new_median == median_price
assert new_ppm2 == median_ppm2
def test_blend_no_op_when_no_anchor() -> None:
"""anchor_total=None → graceful no-op (common case без IMV-данных)."""
new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend(
median_price=12_000_000,
range_high=14_000_000,
median_ppm2=200_000.0,
area=60.0,
anchor_total=None,
anchor_higher=None,
weight=0.5,
threshold=1.15,
)
assert blended is False
assert anchor_used is None
assert new_median == 12_000_000
assert new_range_high == 14_000_000
assert new_ppm2 == 200_000.0
def test_blend_extends_range_to_anchor_when_no_higher() -> None:
"""Нет higher_price → диапазон расширяется до самого якоря."""
_, new_range_high, _, blended, _ = _apply_imv_blend(
median_price=50_000_000,
range_high=60_000_000,
median_ppm2=625_000.0,
area=80.0,
anchor_total=100_000_000,
anchor_higher=None,
weight=0.5,
threshold=1.15,
)
assert blended is True
assert new_range_high == 100_000_000 # до якоря, т.к. higher отсутствует
# ── #651: house_imv anchor lookup (mocked Session) ─────────────────────────────
def test_fetch_house_imv_anchor_none_when_no_house_id() -> None:
"""target_house_id=None → None без обращения к БД."""
mock_db = MagicMock()
assert _fetch_house_imv_anchor(mock_db, target_house_id=None, rooms=2, area=50.0) is None
mock_db.execute.assert_not_called()
def test_fetch_house_imv_anchor_returns_row() -> None:
"""Возвращает dict из house_imv_evaluations при наличии строки."""
mock_db = MagicMock()
mock_db.execute.return_value.mappings.return_value.first.return_value = {
"recommended_price": 31_950_000,
"lower_price": 30_352_500,
"higher_price": 33_547_500,
"market_count": 800,
"rooms": 2,
"area_m2": 80.0,
}
res = _fetch_house_imv_anchor(mock_db, target_house_id=11308, rooms=2, area=80.0)
assert res is not None
assert res["recommended_price"] == 31_950_000
assert res["higher_price"] == 33_547_500
# ── #652: DKP corridor (mocked Session) ────────────────────────────────────────
def test_dkp_corridor_none_without_street() -> None:
"""Адрес без распознаваемой улицы → None."""
mock_db = MagicMock()
assert _fetch_dkp_corridor(mock_db, address="", rooms=2, area=50.0) is None
def test_dkp_corridor_aggregates_ppm2() -> None:
"""Считает low/median/high ₽/м² по сделкам."""
mock_db = MagicMock()
mock_db.execute.return_value.mappings.return_value.all.return_value = [
{"price_per_m2": 100_000},
{"price_per_m2": 120_000},
{"price_per_m2": 140_000},
]
res = _fetch_dkp_corridor(
mock_db, address="улица Ленина, 5", rooms=2, area=50.0
)
assert res is not None
assert res["count"] == 3
assert res["low_ppm2"] == 100_000
assert res["median_ppm2"] == 120_000
assert res["high_ppm2"] == 140_000
# ── #651: anchor band-guard (WHERE clause is exercised via bind params) ─────────
def test_fetch_house_imv_anchor_passes_band_bind_params() -> None:
"""rooms/area проброшены в bind — band-guard WHERE их использует (no :x::type)."""
mock_db = MagicMock()
mock_db.execute.return_value.mappings.return_value.first.return_value = None
_fetch_house_imv_anchor(mock_db, target_house_id=11308, rooms=3, area=80.0)
# Один execute с биндами {hid, rooms, area}; SQL содержит CAST(...) band-guard.
args, _kwargs = mock_db.execute.call_args
sql_text = str(args[0])
binds = args[1]
assert binds == {"hid": 11308, "rooms": 3, "area": 80.0}
# psycopg v3 convention — CAST(:x AS type), никогда :x::type.
assert "::" not in sql_text
assert "abs(rooms - CAST(:rooms AS integer)) <= 1" in sql_text
assert "area_m2 BETWEEN CAST(:area AS double precision) * 0.7" in sql_text
def test_fetch_house_imv_anchor_studio_not_used_for_3room() -> None:
"""Studio-only IMV запись отфильтрована band-guard'ом → anchor None (blend no-op).
Эмулируем БД-семантику WHERE: для target rooms=3 строка rooms=0/area=22 не
проходит abs(0-3)<=1 SQL вернёт 0 строк .first() = None.
"""
mock_db = MagicMock()
# БД с band-guard вернула бы None (studio не матчит 3-комн.).
mock_db.execute.return_value.mappings.return_value.first.return_value = None
res = _fetch_house_imv_anchor(mock_db, target_house_id=11308, rooms=3, area=80.0)
assert res is None
# ── #651/#652: weight clamp + DKP corridor boundary ────────────────────────────
def test_blend_weight_clamped_above_one() -> None:
"""weight=1.5 → clamp к 1.0: blend = anchor (median*0 + anchor*1)."""
area = 50.0
median_price = 10_000_000
new_median, _, new_ppm2, blended, _ = _apply_imv_blend(
median_price=median_price,
range_high=11_000_000,
median_ppm2=median_price / area,
area=area,
anchor_total=20_000_000,
anchor_higher=None,
weight=1.5, # out-of-range → clamp 1.0
threshold=1.15,
)
assert blended is True
assert new_median == 20_000_000 # 10М*0 + 20М*1
assert new_ppm2 == 20_000_000 / area
def test_blend_weight_clamped_below_zero() -> None:
"""weight=-0.2 → clamp к 0.0: blend = median (медиана не двигается)."""
area = 50.0
median_price = 10_000_000
new_median, _, new_ppm2, blended, _ = _apply_imv_blend(
median_price=median_price,
range_high=11_000_000,
median_ppm2=median_price / area,
area=area,
anchor_total=20_000_000,
anchor_higher=None,
weight=-0.2, # out-of-range → clamp 0.0
threshold=1.15,
)
# blended=True (anchor > median×threshold), но w=0 → median неизменна.
assert blended is True
assert new_median == median_price
assert new_ppm2 == median_price / area
def test_dkp_corridor_count_below_three_still_aggregates() -> None:
"""count<3 → corridor всё равно вычисляется (sanity-bound branch уже у caller'а)."""
mock_db = MagicMock()
mock_db.execute.return_value.mappings.return_value.all.return_value = [
{"price_per_m2": 100_000},
{"price_per_m2": 130_000},
]
res = _fetch_dkp_corridor(mock_db, address="улица Ленина, 5", rooms=2, area=50.0)
assert res is not None
assert res["count"] == 2
assert res["low_ppm2"] == 100_000
assert res["high_ppm2"] == 130_000
# ── #651: expected_sold ↔ blended-median consistency (full estimate path) ───────
def _make_listing(*, price_per_m2: float, area_m2: float = 40.0) -> dict[str, Any]:
price_rub = price_per_m2 * area_m2
return {
"source": "cian",
"source_url": "https://cian.ru/offer/1",
"address": "ЕКБ, ул. Учителей, 18",
"lat": 56.838,
"lon": 60.595,
"rooms": 1,
"area_m2": area_m2,
"floor": 4,
"total_floors": 16,
"price_rub": price_rub,
"price_per_m2": price_per_m2,
"listing_date": datetime(2026, 5, 1),
"days_on_market": 10,
"photo_urls": [],
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
"distance_m": 100.0,
"relevance_score": 0.1,
}
_BLEND_ANALOGS: list[dict[str, Any]] = [
_make_listing(price_per_m2=140_000.0),
_make_listing(price_per_m2=150_000.0),
_make_listing(price_per_m2=160_000.0),
]
def _make_fake_geo():
from app.services.geocoder import GeocodeResult
return GeocodeResult(
lat=56.838,
lon=60.595,
full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
provider="nominatim",
)
def _make_payload():
from app.schemas.trade_in import TradeInEstimateInput
return TradeInEstimateInput(
address="ЕКБ, ул. Учителей, 18",
area_m2=40.0,
rooms=1,
floor=4,
total_floors=16,
)
def _run_estimate_with_anchor(
ratio_tuple: tuple[float | None, str | None],
anchor: dict[str, Any] | None,
):
"""estimate_quality со всеми I/O застабленными; IMV-anchor форсирован."""
from app.services.estimator import estimate_quality
db = MagicMock()
payload = _make_payload()
async def _run():
with (
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_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(_BLEND_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=ratio_tuple),
patch("app.services.estimator._fetch_house_imv_anchor", return_value=anchor),
):
return await estimate_quality(payload, db)
return anyio.run(_run)
def test_expected_sold_consistent_with_blended_median() -> None:
"""Когда blend срабатывает — expected_sold выводится из POST-blend median/range.
asking-median pre-blend = 150_000×40 = 6_000_000. Anchor 18_000_000 median×1.15
blend = 6М*0.5 + 18М*0.5 = 12_000_000. expected_sold должен браться от 12М.
"""
ratio = 0.74
anchor = {
"recommended_price": 18_000_000,
"lower_price": 16_000_000,
"higher_price": 20_000_000,
"market_count": 500,
"rooms": 1,
"area_m2": 40.0,
}
est = _run_estimate_with_anchor((ratio, "per_rooms"), anchor)
# Blend сработал: headline median поднялся над pre-blend 6М.
assert est.median_price_rub == 12_000_000
# expected_sold выведен из POST-blend значений — no stale.
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio)
assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio)
assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio)
# Sanity: sold < asking (ratio<1), но НЕ абсурдная «скидка» от stale 6М-базы.
assert est.expected_sold_price_rub == round(12_000_000 * ratio)
def test_expected_sold_unblended_when_anchor_band_rejects() -> None:
"""anchor=None (band-guard отбросил) → blend no-op → expected_sold от чистой median."""
ratio = 0.74
est = _run_estimate_with_anchor((ratio, "per_rooms"), None)
assert est.median_price_rub == 6_000_000 # pre-blend (no blend)
assert est.expected_sold_price_rub == round(6_000_000 * ratio)

View file

@ -92,6 +92,18 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
const showMockDisclaimer = isMockAddress(estimate);
// ── #651: Avito IMV anchor — реальная рыночная оценка дома. Маркер у цены. ──
const imv = estimate.avito_imv;
const imvPrice =
imv && typeof imv.recommended_price === "number" && imv.recommended_price > 0
? imv.recommended_price
: null;
// ── #652: коридор реальных ДКП-сделок (advisory). ₽/м² → млн через площадь. ──
const dkp = estimate.dkp_corridor;
const dkpArea = estimate.area_m2 ?? input.area_m2 ?? 0;
const showDkp =
!!dkp && dkp.count > 0 && dkpArea > 0 && dkp.low_ppm2 > 0 && dkp.high_ppm2 > 0;
// Progressive enrichment state
const needsHouseType = estimate.house_type === null;
const needsRepairState = estimate.repair_state === null;
@ -264,6 +276,15 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
<div className="price-figure-perm2 mono">
{estimate.median_price_per_m2.toLocaleString("ru-RU")} /м²
</div>
{imvPrice !== null && (
<div
className="price-figure-imv mono"
title="Индекс Market Value Avito по этому дому — реальный рыночный якорь"
style={{ marginTop: 6, fontSize: 12, color: "var(--muted)" }}
>
Avito IMV: {formatMln(imvPrice)}
</div>
)}
</div>
{hasSold && (
@ -299,6 +320,20 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
</p>
)}
{showDkp && dkp && (
<p
className="hero-prices-note"
title="Коридор реальных ₽/м² по ДКП-сделкам Росреестра (справочно)"
>
<b>Коридор реальных сделок:</b>{" "}
<span className="mono">
{formatMln(dkp.low_ppm2 * dkpArea)} {formatMln(dkp.high_ppm2 * dkpArea)}
</span>{" "}
· <span className="mono">{dkp.median_ppm2.toLocaleString("ru-RU")} /м²</span> ·{" "}
{dkp.count} ДКП (Росреестр)
</p>
)}
<div className="hero-bars">
<div className="bar-block">
<div className="bar-head">

View file

@ -72,6 +72,27 @@ export interface CianValuationSummary {
// Базис коэффициента asking→sold (S3): по группе комнат либо городской fallback.
export type RatioBasis = "per_rooms" | "global_fallback";
// ── #651: Avito IMV (Индекс Market Value) якорь для target-дома ──
// Реальная рыночная оценка Avito по дому (house_imv_evaluations) — anchor для
// blend'а медианы. Сурфейсится как референсный маркер. null при отсутствии записи.
export interface AvitoImvSummary {
recommended_price: number | null; // рекомендованная цена Avito, ₽
lower_price: number | null; // нижняя граница IMV-коридора, ₽
higher_price: number | null; // верхняя граница IMV-коридора, ₽
market_count: number | null; // объём рынка, на котором построена оценка
}
// ── #652: коридор реальных ДКП-сделок Росреестра (advisory, не клампит) ──
// Агрегаты ₽/м² по сопоставимым сделкам за период. null / count=0 если по улице
// нет сделок. count..period_months — required (бэкенд не отдаёт частичный объект).
export interface DkpCorridor {
count: number; // число ДКП-сделок в выборке
low_ppm2: number; // min ₽/м² по сделкам
median_ppm2: number; // медиана ₽/м²
high_ppm2: number; // max ₽/м²
period_months: number; // окно поиска сделок
}
export interface AggregatedEstimate {
estimate_id: string; // UUID
median_price_rub: number; // ASKING-ориентир: по чему выставлены аналоги
@ -117,6 +138,12 @@ export interface AggregatedEstimate {
has_balcony: boolean | null;
// ── Cian valuation surface ──
cian_valuation?: CianValuationSummary | null;
// ── #651/#652: внешние якоря ──
// avito_imv — реальная Avito IMV-оценка дома, anchor для blend'а медианы.
// dkp_corridor — коридор ₽/м² по ДКП-сделкам Росреестра (advisory). Оба nullable:
// null при отсутствии данных (graceful, common case без регрессий).
avito_imv?: AvitoImvSummary | null;
dkp_corridor?: DkpCorridor | null;
}
// ── Stage 4a/4b response types ──