Anchor-путь имеет hard floor от comp_min_ppm2×(1-tol). Radius-путь аналогичной защиты снизу не имел — quarter-index-вниз или corridor-clamp могли занизить median неоправданно (asymmetry). Добавлен floor: если итоговый median_ppm2 < dkp_low_ppm2 × factor → поднять до floor + лог. Только radius-путь (anchor_tier is None); без dkp_raw → no-op. За флагами estimate_radius_floor_enabled (default True) и estimate_radius_floor_factor (default 0.8).
249 lines
9.7 KiB
Python
249 lines
9.7 KiB
Python
"""Tests for Fix 2: radius-path нижний floor от DKP-коридора (анти-undershoot).
|
||
|
||
Симметрично corridor-clamp (верхний потолок): если median_ppm2 < dkp_low_ppm2 × factor
|
||
и anchor_tier is None → поднимаем до floor. Только radius-путь. Без dkp_raw → no-op.
|
||
|
||
Тесты:
|
||
- radius median ниже dkp_low × factor → median поднята до floor
|
||
- radius median выше dkp_low × factor → no-op (медиана не изменена)
|
||
- dkp_raw is None → no-op (нет базы для floor)
|
||
- anchor-путь (anchor_tier != None) → не затронут floor'ом
|
||
- флаг estimate_radius_floor_enabled=False → no-op
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import anyio
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
|
||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _make_listing(*, price_per_m2: float, area_m2: float = 50.0) -> dict[str, Any]:
|
||
return {
|
||
"source": "cian",
|
||
"source_url": "https://cian.ru/offer/1",
|
||
"address": "ЕКБ, ул. Учителей, 18",
|
||
"lat": 56.838,
|
||
"lon": 60.595,
|
||
"rooms": 2,
|
||
"area_m2": area_m2,
|
||
"floor": 5,
|
||
"total_floors": 16,
|
||
"price_rub": price_per_m2 * area_m2,
|
||
"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": 150.0,
|
||
"relevance_score": 0.1,
|
||
}
|
||
|
||
|
||
def _make_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=50.0,
|
||
rooms=2,
|
||
floor=5,
|
||
total_floors=16,
|
||
)
|
||
|
||
|
||
def _run_estimate(
|
||
analogs: list[dict[str, Any]],
|
||
dkp_raw: dict[str, Any] | None,
|
||
*,
|
||
radius_floor_enabled: bool = True,
|
||
radius_floor_factor: float = 0.8,
|
||
) -> Any:
|
||
from app.core.config import settings
|
||
from app.services.estimator import estimate_quality
|
||
|
||
db = MagicMock()
|
||
payload = _make_payload()
|
||
|
||
async def _run() -> Any:
|
||
with (
|
||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_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._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._fetch_dkp_corridor", return_value=dkp_raw),
|
||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||
patch.object(settings, "estimate_radius_floor_enabled", radius_floor_enabled),
|
||
patch.object(settings, "estimate_radius_floor_factor", radius_floor_factor),
|
||
):
|
||
return await estimate_quality(payload, db)
|
||
|
||
return anyio.run(_run)
|
||
|
||
|
||
# ── тест 1: radius median ниже floor → поднята ───────────────────────────────
|
||
|
||
|
||
def test_radius_median_below_dkp_floor_is_lifted() -> None:
|
||
"""Radius median = 80k, dkp_low = 150k, factor 0.8 → floor = 120k → median поднята."""
|
||
# analogs с median ~80k
|
||
analogs = [
|
||
_make_listing(price_per_m2=75_000.0),
|
||
_make_listing(price_per_m2=80_000.0),
|
||
_make_listing(price_per_m2=85_000.0),
|
||
]
|
||
dkp_raw = {
|
||
"count": 15,
|
||
"low_ppm2": 150_000,
|
||
"median_ppm2": 180_000,
|
||
"high_ppm2": 220_000,
|
||
"period_months": 12,
|
||
}
|
||
est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8)
|
||
|
||
floor_ppm2 = 150_000 * 0.8 # 120_000
|
||
assert (
|
||
est.median_price_per_m2 >= floor_ppm2
|
||
), f"median_ppm2={est.median_price_per_m2} должна быть >= floor={floor_ppm2}"
|
||
|
||
|
||
# ── тест 2: radius median выше floor → no-op ─────────────────────────────────
|
||
|
||
|
||
def test_radius_median_above_dkp_floor_unchanged() -> None:
|
||
"""Radius median = 200k > dkp_low × factor = 150k × 0.8 = 120k → no-op."""
|
||
analogs = [
|
||
_make_listing(price_per_m2=190_000.0),
|
||
_make_listing(price_per_m2=200_000.0),
|
||
_make_listing(price_per_m2=210_000.0),
|
||
]
|
||
dkp_raw = {
|
||
"count": 15,
|
||
"low_ppm2": 150_000,
|
||
"median_ppm2": 180_000,
|
||
"high_ppm2": 220_000,
|
||
"period_months": 12,
|
||
}
|
||
est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8)
|
||
|
||
# floor = 150k × 0.8 = 120k; median = 200k > floor → no-op, медиана не изменяется
|
||
# Медиана должна остаться в диапазоне аналогов (190-210k), а не подняться к floor.
|
||
floor_ppm2 = 150_000 * 0.8 # 120_000
|
||
assert est.median_price_per_m2 > floor_ppm2, "median должна быть выше floor (no-op)"
|
||
# Медиана соответствует аналогам (~200k), а не floor
|
||
assert (
|
||
180_000 <= est.median_price_per_m2 <= 220_000
|
||
), f"median_ppm2={est.median_price_per_m2} должна остаться в диапазоне аналогов (no-op)"
|
||
|
||
|
||
# ── тест 3: dkp_raw is None → no-op ─────────────────────────────────────────
|
||
|
||
|
||
def test_no_dkp_raw_no_floor() -> None:
|
||
"""Без dkp_raw floor не применяется — median остаётся radius-значением."""
|
||
analogs = [
|
||
_make_listing(price_per_m2=75_000.0),
|
||
_make_listing(price_per_m2=80_000.0),
|
||
_make_listing(price_per_m2=85_000.0),
|
||
]
|
||
est = _run_estimate(analogs, dkp_raw=None, radius_floor_factor=0.8)
|
||
|
||
# median ~80k, без dkp_raw floor не поднимает
|
||
assert (
|
||
est.median_price_per_m2 < 100_000
|
||
), f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти"
|
||
|
||
|
||
# ── тест 4: флаг OFF → no-op ─────────────────────────────────────────────────
|
||
|
||
|
||
def test_radius_floor_disabled_no_op() -> None:
|
||
"""estimate_radius_floor_enabled=False → floor не применяется."""
|
||
analogs = [
|
||
_make_listing(price_per_m2=75_000.0),
|
||
_make_listing(price_per_m2=80_000.0),
|
||
_make_listing(price_per_m2=85_000.0),
|
||
]
|
||
dkp_raw = {
|
||
"count": 15,
|
||
"low_ppm2": 150_000,
|
||
"median_ppm2": 180_000,
|
||
"high_ppm2": 220_000,
|
||
"period_months": 12,
|
||
}
|
||
# Без флага — median не должна подняться
|
||
from app.core.config import settings
|
||
from app.services.estimator import estimate_quality
|
||
|
||
db = MagicMock()
|
||
payload = _make_payload()
|
||
|
||
async def _run() -> Any:
|
||
with (
|
||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_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._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._fetch_dkp_corridor", return_value=dkp_raw),
|
||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||
patch.object(settings, "estimate_radius_floor_enabled", False),
|
||
):
|
||
return await estimate_quality(payload, db)
|
||
|
||
est = anyio.run(_run)
|
||
assert (
|
||
est.median_price_per_m2 < 100_000
|
||
), f"Флаг OFF: median_ppm2={est.median_price_per_m2} не должна расти"
|