fix(estimator): clamp expected_sold <= asking (ratio cap 1.0) -- smoke 3к exp_sold>asking
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped

This commit is contained in:
bot-backend 2026-06-21 00:35:23 +03:00
parent f2cfe66b02
commit b2cd2925fd
3 changed files with 208 additions and 4 deletions

View file

@ -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 тянется вверх).

View file

@ -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) — переиспользуем, не фетчим повторно.

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