gendesign/tradein-mvp/backend/tests/test_estimator_radius_floor.py
bot-backend b6ea986134 fix(estimator): radius-path нижний floor от DKP-коридора (анти-undershoot)
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).
2026-06-21 00:25:05 +03:00

249 lines
9.7 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.

"""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} не должна расти"