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) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
|
||||||
# (tier-C гейт ×1.5 ловит явную контаминацию выше).
|
# (tier-C гейт ×1.5 ловит явную контаминацию выше).
|
||||||
estimate_corridor_clamp_slack: float = 0.40
|
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:
|
# Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor:
|
||||||
# при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k
|
# при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k
|
||||||
# (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх).
|
# (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-мутаций:
|
# 4c (cont.). expected_sold_* выводим ЗДЕСЬ — ПОСЛЕ всех headline-мутаций:
|
||||||
# #651 IMV-blend / SB-anchor / quarter-index / #1795 corridor-clamp / radius-floor.
|
# #651 IMV-blend / SB-anchor / quarter-index / #1795 corridor-clamp / radius-floor.
|
||||||
# FIX(ratio-tier-mismatch): _get_asking_sold_ratio вызывается с ФИНАЛЬНЫМ
|
# 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