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).
This commit is contained in:
bot-backend 2026-06-21 00:25:05 +03:00
parent 352dfc8053
commit b6ea986134
3 changed files with 292 additions and 0 deletions

View file

@ -111,6 +111,14 @@ class Settings(BaseSettings):
# (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
# (tier-C гейт ×1.5 ловит явную контаминацию выше).
estimate_corridor_clamp_slack: float = 0.40
# Нижний floor для radius-пути: симметрично corridor-clamp сверху, но снизу.
# Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
# Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
# factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
# (избегаем ложных подъёмов) и ловит явный undershoot. False → no-op.
# ENV: ESTIMATE_RADIUS_FLOOR_ENABLED, ESTIMATE_RADIUS_FLOOR_FACTOR.
estimate_radius_floor_enabled: bool = True
estimate_radius_floor_factor: float = 0.8
# Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor:
# при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k
# (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх).

View file

@ -2794,6 +2794,41 @@ async def estimate_quality(
" Оценка ограничена коридором реальных сделок Росреестра по улице."
)
# ── FIX: radius-path нижний floor от DKP-коридора (анти-undershoot) ────────
# Anchor-путь имеет hard floor (comp_min_ppm2×(1-tol)) — radius-путь аналогичной
# защиты снизу не имел: quarter-index-вниз или corridor-clamp могли занизить
# median неоправданно (асимметрия с anchor-path). Если dkp_raw доступен и
# итоговый median_ppm2 < dkp_low_ppm2 × factor → поднимаем до floor и логируем.
# Только radius-путь (anchor_tier is None); без dkp_raw → no-op.
# За флагом estimate_radius_floor_enabled (дефолт True).
if (
settings.estimate_radius_floor_enabled
and anchor_tier is None
and dkp_raw is not None
and dkp_raw.get("low_ppm2", 0) > 0
and median_ppm2 > 0
and dkp_raw.get("count", 0) >= settings.estimate_corridor_clamp_min_n
):
radius_floor_ppm2 = float(dkp_raw["low_ppm2"]) * settings.estimate_radius_floor_factor
if median_ppm2 < radius_floor_ppm2:
floor_factor = radius_floor_ppm2 / median_ppm2
logger.info(
"radius_floor: median_ppm2=%d < dkp_low=%d × factor=%.2f = floor=%d"
" → lifting (factor=%.3f)",
int(median_ppm2),
dkp_raw["low_ppm2"],
settings.estimate_radius_floor_factor,
int(radius_floor_ppm2),
floor_factor,
)
median_ppm2 = radius_floor_ppm2
median_price = round(median_price * floor_factor)
range_low = round(range_low * floor_factor)
range_high = round(range_high * floor_factor)
explanation = (explanation or "") + (
" Оценка поднята до нижней границы коридора реальных сделок Росреестра."
)
# 4c (cont.). expected_sold_* выводим ЗДЕСЬ — ПОСЛЕ всех headline-мутаций:
# #651 IMV-blend / SB-anchor / quarter-index / #1795 corridor-clamp / radius-floor.
# FIX(ratio-tier-mismatch): _get_asking_sold_ratio вызывается с ФИНАЛЬНЫМ

View file

@ -0,0 +1,249 @@
"""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} не должна расти"