feat(tradein/estimator): elite-segment manual_review trigger by asking ppm² (#2002)
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:
parent
ec0f113d4f
commit
aa65809cd2
3 changed files with 128 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue