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_enabled: bool = True
|
||||||
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
|
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
|
||||||
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
|
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 ───────────────────────
|
# ── #1871 P2: radius-tier (source, source_id) dedup ───────────────────────
|
||||||
# Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address
|
# Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address
|
||||||
|
|
|
||||||
|
|
@ -3102,6 +3102,9 @@ async def estimate_quality(
|
||||||
range_low,
|
range_low,
|
||||||
range_high,
|
range_high,
|
||||||
settings,
|
settings,
|
||||||
|
# asking-сторона: тот же headline median_price_per_m2 (=int(median_ppm2)),
|
||||||
|
# что уходит в ответ ниже. НЕ expected_sold_per_m2 — он занижен на элите.
|
||||||
|
asking_ppm2=int(median_ppm2),
|
||||||
)
|
)
|
||||||
|
|
||||||
return AggregatedEstimate(
|
return AggregatedEstimate(
|
||||||
|
|
@ -3245,6 +3248,7 @@ def _manual_review(
|
||||||
range_low: int | None,
|
range_low: int | None,
|
||||||
range_high: int | None,
|
range_high: int | None,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
|
asking_ppm2: int | None = None,
|
||||||
) -> tuple[bool, list[str]]:
|
) -> tuple[bool, list[str]]:
|
||||||
"""#2002 #4: рекомендация ручной оценки — производный ФЛАГ, НЕ цена.
|
"""#2002 #4: рекомендация ручной оценки — производный ФЛАГ, НЕ цена.
|
||||||
|
|
||||||
|
|
@ -3258,7 +3262,13 @@ def _manual_review(
|
||||||
- premium_building — премиальный дом;
|
- premium_building — премиальный дом;
|
||||||
- expected_sold_price ≥ high_value_rub — элит-зона по абсолютной стоимости;
|
- expected_sold_price ≥ high_value_rub — элит-зона по абсолютной стоимости;
|
||||||
- confidence == "low" — низкая уверенность оценки;
|
- 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).
|
Возвращает (recommended, reasons), где recommended == bool(reasons).
|
||||||
При выключенном флаге — (False, []). Деление на ноль исключено: проверка
|
При выключенном флаге — (False, []). Деление на ноль исключено: проверка
|
||||||
|
|
@ -3277,6 +3287,11 @@ def _manual_review(
|
||||||
wide_ratio = settings.estimate_manual_review_wide_range_ratio
|
wide_ratio = settings.estimate_manual_review_wide_range_ratio
|
||||||
if range_low and range_high and range_high / range_low >= wide_ratio:
|
if range_low and range_high and range_high / range_low >= wide_ratio:
|
||||||
reasons.append("широкий диапазон цены")
|
reasons.append("широкий диапазон цены")
|
||||||
|
if asking_ppm2 and asking_ppm2 >= settings.estimate_manual_review_elite_ppm2:
|
||||||
|
reasons.append(
|
||||||
|
"элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, "
|
||||||
|
"вид и класс дома не отражена в данных сделок, фактическая цена может быть выше"
|
||||||
|
)
|
||||||
return (bool(reasons), reasons)
|
return (bool(reasons), reasons)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ PREMIUM_REASON = "премиальный дом — премия зависит
|
||||||
HIGH_VALUE_REASON = "высокая стоимость (≥20 млн ₽)"
|
HIGH_VALUE_REASON = "высокая стоимость (≥20 млн ₽)"
|
||||||
LOW_CONF_REASON = "низкая уверенность оценки"
|
LOW_CONF_REASON = "низкая уверенность оценки"
|
||||||
WIDE_RANGE_REASON = "широкий диапазон цены"
|
WIDE_RANGE_REASON = "широкий диапазон цены"
|
||||||
|
ELITE_REASON = (
|
||||||
|
"элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, "
|
||||||
|
"вид и класс дома не отражена в данных сделок, фактическая цена может быть выше"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _settings(
|
def _settings(
|
||||||
|
|
@ -35,12 +39,14 @@ def _settings(
|
||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
high_value: int = 20_000_000,
|
high_value: int = 20_000_000,
|
||||||
wide_ratio: float = 1.9,
|
wide_ratio: float = 1.9,
|
||||||
|
elite_ppm2: int = 250_000,
|
||||||
) -> SimpleNamespace:
|
) -> SimpleNamespace:
|
||||||
"""Duck-typed заглушка Settings — helper читает только эти три поля."""
|
"""Duck-typed заглушка Settings — helper читает только эти поля."""
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
estimate_manual_review_enabled=enabled,
|
estimate_manual_review_enabled=enabled,
|
||||||
estimate_manual_review_high_value_rub=high_value,
|
estimate_manual_review_high_value_rub=high_value,
|
||||||
estimate_manual_review_wide_range_ratio=wide_ratio,
|
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]
|
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 ──────────────────────────────────────────────
|
# ── Комбинированный / all-clear ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue