fix(estimator): clamp expected_sold <= asking (ratio cap 1.0) -- smoke 3к exp_sold>asking
This commit is contained in:
parent
f2cfe66b02
commit
b2cd2925fd
3 changed files with 208 additions and 4 deletions
|
|
@ -119,6 +119,17 @@ class Settings(BaseSettings):
|
|||
# ENV: ESTIMATE_RADIUS_FLOOR_ENABLED, ESTIMATE_RADIUS_FLOOR_FACTOR.
|
||||
estimate_radius_floor_enabled: bool = True
|
||||
estimate_radius_floor_factor: float = 0.8
|
||||
# Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
|
||||
# trade-in (ожидаемая цена сделки не должна превышать цену объявления).
|
||||
# Диагноз: в high-price tier asking->sold ratio > 1.0 (product artefact, не реальные
|
||||
# сделки выше прайса) -> expected_sold = headline x ratio > headline.
|
||||
# При флаге True: если ratio > 1.0 — клампаем до 1.0 и логируем. Применяется
|
||||
# к point И range (expected_sold_low/high/price) консистентно.
|
||||
# False -> старое поведение без clamp (backward-compat).
|
||||
# ENV: ESTIMATE_EXPECTED_SOLD_LE_ASKING.
|
||||
estimate_expected_sold_le_asking: bool = Field(
|
||||
default=True, validation_alias="ESTIMATE_EXPECTED_SOLD_LE_ASKING"
|
||||
)
|
||||
# Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor:
|
||||
# при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k
|
||||
# (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх).
|
||||
|
|
|
|||
|
|
@ -2861,10 +2861,22 @@ async def estimate_quality(
|
|||
ratio_basis = None
|
||||
|
||||
if asking_to_sold_ratio is not None and median_price > 0:
|
||||
expected_sold_per_m2 = round(median_ppm2 * asking_to_sold_ratio)
|
||||
expected_sold_price = round(median_price * asking_to_sold_ratio)
|
||||
expected_sold_range_low = round(range_low * asking_to_sold_ratio)
|
||||
expected_sold_range_high = round(range_high * asking_to_sold_ratio)
|
||||
# FIX(expected_sold-le-asking): clamp ratio <= 1.0 чтобы expected_sold не
|
||||
# превышала объявление. Smoke: 3к/27.0М -> expected_sold 31.4М > asking.
|
||||
# Причина: high-price tier ratio > 1.0 (product artefact, не реальные сделки
|
||||
# выше прайса). Флаг OFF -> старое поведение без clamp.
|
||||
effective_ratio = asking_to_sold_ratio
|
||||
if settings.estimate_expected_sold_le_asking and effective_ratio > 1.0:
|
||||
logger.info(
|
||||
"expected_sold ratio clamped %.3f->1.0 (rooms=%s)",
|
||||
effective_ratio,
|
||||
payload.rooms,
|
||||
)
|
||||
effective_ratio = 1.0
|
||||
expected_sold_per_m2 = round(median_ppm2 * effective_ratio)
|
||||
expected_sold_price = round(median_price * effective_ratio)
|
||||
expected_sold_range_low = round(range_low * effective_ratio)
|
||||
expected_sold_range_high = round(range_high * effective_ratio)
|
||||
|
||||
# ── #652: ДКП-коридор реальных сделок (ADVISORY + soft sanity-bound) ─────
|
||||
# dkp_raw уже зафетчен выше (#1795) — переиспользуем, не фетчим повторно.
|
||||
|
|
|
|||
181
tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py
Normal file
181
tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""Tests for Fix 4: clamp expected_sold <= asking (ratio cap 1.0).
|
||||
|
||||
Smoke-диагноз: 3к/27.0М -> expected_sold 31.4М > asking.
|
||||
Причина: _get_asking_sold_ratio для high-price tier возвращает ratio > 1.0
|
||||
(product artefact) -> expected_sold = headline x ratio > headline.
|
||||
|
||||
Проверяем:
|
||||
1. ratio=1.15 + флаг on -> expected_sold == headline (не выше); лог clamp.
|
||||
2. ratio=0.8 -> expected_sold = headline x 0.8 (норм, без clamp).
|
||||
3. ratio None -> expected_sold не выводится (graceful).
|
||||
4. Флаг off + ratio > 1 -> expected_sold > headline (старое поведение).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
|
||||
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": "ЕКБ, ул. Малышева, 30",
|
||||
"lat": 56.838,
|
||||
"lon": 60.595,
|
||||
"rooms": 3,
|
||||
"area_m2": area_m2,
|
||||
"floor": 10,
|
||||
"total_floors": 20,
|
||||
"price_rub": price_per_m2 * area_m2,
|
||||
"price_per_m2": price_per_m2,
|
||||
"listing_date": datetime(2026, 5, 1),
|
||||
"days_on_market": 5,
|
||||
"photo_urls": [],
|
||||
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
|
||||
"distance_m": 100.0,
|
||||
"relevance_score": 0.1,
|
||||
}
|
||||
|
||||
|
||||
_ANALOGS: list[dict[str, Any]] = [
|
||||
_make_listing(price_per_m2=260_000.0),
|
||||
_make_listing(price_per_m2=270_000.0),
|
||||
_make_listing(price_per_m2=280_000.0),
|
||||
]
|
||||
|
||||
|
||||
def _make_geo():
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
||||
return GeocodeResult(
|
||||
lat=56.838,
|
||||
lon=60.595,
|
||||
full_address="Свердловская обл., Екатеринбург, ул. Малышева, 30",
|
||||
provider="nominatim",
|
||||
)
|
||||
|
||||
|
||||
def _make_payload(rooms: int = 3):
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
|
||||
return TradeInEstimateInput(
|
||||
address="ЕКБ, ул. Малышева, 30", area_m2=50.0, rooms=rooms, floor=10, total_floors=20
|
||||
)
|
||||
|
||||
|
||||
def _run_estimate(
|
||||
ratio_tuple: tuple[float | None, str | None],
|
||||
*,
|
||||
clamp_enabled: bool = True,
|
||||
) -> Any:
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
payload = _make_payload()
|
||||
|
||||
async def _run() -> Any:
|
||||
with (
|
||||
patch("app.core.config.settings.estimate_expected_sold_le_asking", new=clamp_enabled),
|
||||
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=None),
|
||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=ratio_tuple),
|
||||
):
|
||||
return await estimate_quality(payload, db)
|
||||
|
||||
return anyio.run(_run)
|
||||
|
||||
|
||||
def test_expected_sold_clamped_to_headline_when_ratio_above_1(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
ratio = 1.15
|
||||
with caplog.at_level(logging.INFO, logger="app.services.estimator"):
|
||||
est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True)
|
||||
assert est.median_price_rub > 0, "headline должен быть задан аналогами"
|
||||
assert est.expected_sold_price_rub is not None
|
||||
assert (
|
||||
est.expected_sold_price_rub <= est.median_price_rub
|
||||
), f"expected_sold {est.expected_sold_price_rub} > asking {est.median_price_rub}"
|
||||
assert est.expected_sold_per_m2 is not None
|
||||
assert (
|
||||
est.expected_sold_per_m2 <= est.median_price_per_m2
|
||||
), "expected_sold_per_m2 превышает median_price_per_m2"
|
||||
assert est.expected_sold_range_high_rub is not None
|
||||
assert (
|
||||
est.expected_sold_range_high_rub <= est.range_high_rub
|
||||
), "expected_sold_range_high > range_high"
|
||||
assert est.expected_sold_range_low_rub is not None
|
||||
assert (
|
||||
est.expected_sold_range_low_rub <= est.range_low_rub
|
||||
), "expected_sold_range_low > range_low"
|
||||
assert est.expected_sold_price_rub == est.median_price_rub
|
||||
assert est.expected_sold_per_m2 == est.median_price_per_m2
|
||||
clamp_logs = [r for r in caplog.records if "clamped" in r.getMessage()]
|
||||
assert clamp_logs, "Ожидался log-message о clamp ratio"
|
||||
assert "1.150" in clamp_logs[0].getMessage() or "1.15" in clamp_logs[0].getMessage()
|
||||
|
||||
|
||||
def test_expected_sold_not_clamped_when_ratio_below_1() -> None:
|
||||
ratio = 0.8
|
||||
est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True)
|
||||
assert est.median_price_rub > 0
|
||||
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
|
||||
assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio)
|
||||
assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio)
|
||||
assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio)
|
||||
assert est.expected_sold_price_rub < est.median_price_rub
|
||||
|
||||
|
||||
def test_expected_sold_none_when_ratio_none() -> None:
|
||||
est = _run_estimate((None, None), clamp_enabled=True)
|
||||
assert est.expected_sold_price_rub is None
|
||||
assert est.expected_sold_per_m2 is None
|
||||
assert est.expected_sold_range_low_rub is None
|
||||
assert est.expected_sold_range_high_rub is None
|
||||
assert est.asking_to_sold_ratio is None
|
||||
assert est.median_price_rub > 0
|
||||
|
||||
|
||||
def test_expected_sold_exceeds_asking_when_clamp_flag_off() -> None:
|
||||
ratio = 1.15
|
||||
est = _run_estimate((ratio, "per_rooms"), clamp_enabled=False)
|
||||
assert est.median_price_rub > 0
|
||||
assert est.expected_sold_price_rub is not None
|
||||
assert est.expected_sold_price_rub > est.median_price_rub, (
|
||||
f"Ожидалось expected_sold > asking при clamp=False, "
|
||||
f"но {est.expected_sold_price_rub} <= {est.median_price_rub}"
|
||||
)
|
||||
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
|
||||
Loading…
Add table
Reference in a new issue