gendesign/tradein-mvp/backend/tests/test_estimator_imv_blend.py
lekss361 70ee7b282c feat(tradein): blend Avito IMV anchor into estimate + ДКП corridor advisory — backend (#651, #652)
Radius-median underestimated premium/view units ~2x. #651: blend real Avito
IMV (house_imv_evaluations per house_id) one-directional raise-only — when
IMV recommended > median*1.15, median = blend(median,IMV,w=0.5), range_high
extends to IMV higher. #652: ДКП corridor (rosreestr deals, RUB/m2, advisory
+ soft +-25% note, no clamp). Behind config flags (default on), null-guarded
no-op when absent. Adds avito_imv + dkp_corridor to AggregatedEstimate.
Validated on live DB (B. Yeltsina 6 80m2: 19.7M->25.8M, range->33.5M).
Frontend surfacing (tradein-mvp/frontend) follows in next commit.
2026-05-29 18:58:10 +03:00

190 lines
6.8 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
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
from unittest.mock import MagicMock
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