diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index ff7c2f9d..666667b3 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -228,6 +228,9 @@ class Settings(BaseSettings): estimate_manual_review_enabled: bool = True estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо + # asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна + # (премия за отделку/вид/класс — unit-level, отсутствует в данных сделок). + estimate_manual_review_elite_ppm2: int = 250000 # ── #1871 P2: radius-tier (source, source_id) dedup ─────────────────────── # Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index f49d7350..fb039db5 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -3102,6 +3102,9 @@ async def estimate_quality( range_low, range_high, settings, + # asking-сторона: тот же headline median_price_per_m2 (=int(median_ppm2)), + # что уходит в ответ ниже. НЕ expected_sold_per_m2 — он занижен на элите. + asking_ppm2=int(median_ppm2), ) return AggregatedEstimate( @@ -3245,6 +3248,7 @@ def _manual_review( range_low: int | None, range_high: int | None, settings: Settings, + asking_ppm2: int | None = None, ) -> tuple[bool, list[str]]: """#2002 #4: рекомендация ручной оценки — производный ФЛАГ, НЕ цена. @@ -3258,7 +3262,13 @@ def _manual_review( - premium_building — премиальный дом; - expected_sold_price ≥ high_value_rub — элит-зона по абсолютной стоимости; - confidence == "low" — низкая уверенность оценки; - - range_high / range_low ≥ wide_range_ratio — слишком широкий диапазон. + - range_high / range_low ≥ wide_range_ratio — слишком широкий диапазон; + - asking_ppm2 ≥ elite_ppm2 — дорогой сегмент по asking ₽/м². + + `asking_ppm2` — headline/«по объявлениям» медиана ₽/м² (НЕ expected_sold_per_m2): + бэктест показал систематическую недооценку бизнес/элита (bias ≈ −29%), потому что + expected_sold затягивает элит-юнит в бизнес-полосу и не опознаёт его. Asking-сторона + не занижена и чисто маркирует дорогой сегмент. Возвращает (recommended, reasons), где recommended == bool(reasons). При выключенном флаге — (False, []). Деление на ноль исключено: проверка @@ -3277,6 +3287,11 @@ def _manual_review( wide_ratio = settings.estimate_manual_review_wide_range_ratio if range_low and range_high and range_high / range_low >= wide_ratio: reasons.append("широкий диапазон цены") + if asking_ppm2 and asking_ppm2 >= settings.estimate_manual_review_elite_ppm2: + reasons.append( + "элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, " + "вид и класс дома не отражена в данных сделок, фактическая цена может быть выше" + ) return (bool(reasons), reasons) diff --git a/tradein-mvp/backend/tests/test_estimator_manual_review.py b/tradein-mvp/backend/tests/test_estimator_manual_review.py index 5baa0d4e..5c38a955 100644 --- a/tradein-mvp/backend/tests/test_estimator_manual_review.py +++ b/tradein-mvp/backend/tests/test_estimator_manual_review.py @@ -28,6 +28,10 @@ PREMIUM_REASON = "премиальный дом — премия зависит HIGH_VALUE_REASON = "высокая стоимость (≥20 млн ₽)" LOW_CONF_REASON = "низкая уверенность оценки" WIDE_RANGE_REASON = "широкий диапазон цены" +ELITE_REASON = ( + "элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, " + "вид и класс дома не отражена в данных сделок, фактическая цена может быть выше" +) def _settings( @@ -35,12 +39,14 @@ def _settings( enabled: bool = True, high_value: int = 20_000_000, wide_ratio: float = 1.9, + elite_ppm2: int = 250_000, ) -> SimpleNamespace: - """Duck-typed заглушка Settings — helper читает только эти три поля.""" + """Duck-typed заглушка Settings — helper читает только эти поля.""" return SimpleNamespace( estimate_manual_review_enabled=enabled, estimate_manual_review_high_value_rub=high_value, estimate_manual_review_wide_range_ratio=wide_ratio, + estimate_manual_review_elite_ppm2=elite_ppm2, ) @@ -99,6 +105,108 @@ def test_wide_range_alone() -> None: assert reasons == [WIDE_RANGE_REASON] +def test_elite_ppm2_alone() -> None: + """asking_ppm2 ≥ elite_ppm2 → только элит-причина, даже когда оценка < 20 млн. + + Юнит НЕ в premium-доме, expected_sold < high_value, confidence не low, + диапазон узкий — без asking-триггера остался бы без предупреждения. + """ + rec, reasons = _manual_review( + premium_building=False, + expected_sold_price=12_000_000, + confidence="high", + range_low=11_000_000, + range_high=13_000_000, # ratio ~1.18 < 1.9 + settings=_settings(), + asking_ppm2=300_000, # ≥ 250_000 + ) + assert rec is True + assert reasons == [ELITE_REASON] + + +def test_elite_ppm2_boundary_inclusive() -> None: + """asking_ppm2 == elite_ppm2 → триггерит (>=).""" + rec, reasons = _manual_review( + premium_building=False, + expected_sold_price=12_000_000, + confidence="high", + range_low=11_000_000, + range_high=13_000_000, + settings=_settings(), + asking_ppm2=250_000, + ) + assert rec is True + assert reasons == [ELITE_REASON] + + +def test_elite_ppm2_just_below_threshold() -> None: + """asking_ppm2 на 1 ₽ ниже порога → без элит-причины.""" + rec, reasons = _manual_review( + premium_building=False, + expected_sold_price=12_000_000, + confidence="high", + range_low=11_000_000, + range_high=13_000_000, + settings=_settings(), + asking_ppm2=249_999, + ) + assert rec is False + assert reasons == [] + + +def test_elite_ppm2_none_safe() -> None: + """asking_ppm2 None (дефолт) → элит-триггер не срабатывает, без ошибок.""" + rec, reasons = _manual_review( + premium_building=False, + expected_sold_price=12_000_000, + confidence="high", + range_low=11_000_000, + range_high=13_000_000, + settings=_settings(), + asking_ppm2=None, + ) + assert rec is False + assert reasons == [] + assert ELITE_REASON not in reasons + + +def test_elite_ppm2_zero_safe() -> None: + """asking_ppm2 == 0 (falsy) → без деления/триггера, без элит-причины.""" + rec, reasons = _manual_review( + premium_building=False, + expected_sold_price=12_000_000, + confidence="high", + range_low=11_000_000, + range_high=13_000_000, + settings=_settings(), + asking_ppm2=0, + ) + assert rec is False + assert ELITE_REASON not in reasons + + +def test_elite_ppm2_combined_with_other_triggers() -> None: + """Элит-причина аддитивна: premium + high_value + low_conf + wide + elite.""" + rec, reasons = _manual_review( + premium_building=True, + expected_sold_price=30_000_000, + confidence="low", + range_low=10_000_000, + range_high=25_000_000, # ratio 2.5 ≥ 1.9 + settings=_settings(), + asking_ppm2=400_000, + ) + assert rec is True + # порядок детерминирован: premium → high_value → low_conf → wide_range → elite + assert reasons == [ + PREMIUM_REASON, + HIGH_VALUE_REASON, + LOW_CONF_REASON, + WIDE_RANGE_REASON, + ELITE_REASON, + ] + + # ── Комбинированный / all-clear ──────────────────────────────────────────────