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.
190 lines
6.8 KiB
Python
190 lines
6.8 KiB
Python
"""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
|