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:
parent
352dfc8053
commit
b6ea986134
3 changed files with 292 additions and 0 deletions
|
|
@ -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 тянется вверх).
|
||||
|
|
|
|||
|
|
@ -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 вызывается с ФИНАЛЬНЫМ
|
||||
|
|
|
|||
249
tradein-mvp/backend/tests/test_estimator_radius_floor.py
Normal file
249
tradein-mvp/backend/tests/test_estimator_radius_floor.py
Normal 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} не должна расти"
|
||||
Loading…
Add table
Reference in a new issue