feat(tradein/estimator): elite-segment manual_review trigger by asking ppm² (#2002)
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

Backtest показал систематическую недооценку бизнес/элита (bias ≈ −29%):
премия unit-level (отделка/вид/класс) отсутствует в данных сделок, поэтому
expected_sold затягивает элит-юнит в бизнес-полосу. Элит-юнит вне curated
premium-дома, < 20 млн и с приличной уверенностью раньше не получал предупреждения.

Новый триггер _manual_review по asking/headline ₽/м² (median_price_per_m2,
«по объявлениям» — НЕ занижённый expected_sold_per_m2): asking_ppm2 ≥
estimate_manual_review_elite_ppm2 (250к) → честное предупреждение, что
авто-оценка консервативна. Только метаданные — median/expected_sold/ranges
не тронуты, regression-gate byte-identical.
This commit is contained in:
bot-backend 2026-06-27 23:11:55 +03:00
parent ec0f113d4f
commit aa65809cd2
3 changed files with 128 additions and 2 deletions

View file

@ -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

View file

@ -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)

View file

@ -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 ──────────────────────────────────────────────