gendesign/tradein-mvp/backend/tests/test_estimator_imv_blend.py
lekss361 c45445b726
All checks were successful
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 / build-backend (push) Successful in 54s
Deploy Trade-In / deploy (push) Successful in 47s
Deploy Trade-In / test (push) Successful in 1m25s
feat(tradein/estimator): hedonic year+area correction on expected_sold (#2002)
2026-06-27 16:06:08 +00:00

438 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 ₽/м² по сделкам.
#1520: low/high — P10/P90 (не min/max), устойчивы к выбросам.
Для 3 значений [100k, 120k, 140k]:
P10 = 100k + (120k-100k)*0.2 = 104k
P50 = 120k
P90 = 120k + (140k-120k)*0.8 = 136k
"""
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},
]
with patch("app.services.estimator._load_sber_index_series", return_value={}):
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"] == 104_000 # P10
assert res["median_ppm2"] == 120_000
assert res["high_ppm2"] == 136_000 # P90
# ── #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'а).
#1520: P10/P90 при n=2 [100k, 130k] (интерполяция по диапазону 30k):
P10 = 100k + 30k*0.1 = 103k
P90 = 100k + 30k*0.9 = 127k
"""
mock_db = MagicMock()
mock_db.execute.return_value.mappings.return_value.all.return_value = [
{"price_per_m2": 100_000},
{"price_per_m2": 130_000},
]
with patch("app.services.estimator._load_sber_index_series", return_value={}):
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"] == 103_000 # P10
assert res["high_ppm2"] == 127_000 # P90
# ── #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 (
# #2002: these tests assert expected_sold == post-blend headline × ratio.
# Hold the orthogonal hedonic correction OFF (OFF ⇒ legacy expected_sold).
patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False),
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)
# #1966: expected_sold range — калиброванный ~80% PI вокруг точки, не asking-IQR × ratio.
from app.core.config import settings
assert est.expected_sold_range_high_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_high_mult
)
assert est.expected_sold_range_low_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_low_mult
)
# 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)